An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Validation, Type Classes, bimap, and traverse/sequence

+1356 -11
+1
src/data/index.ts
··· 8 8 export * from "./array" 9 9 export * from "./units" 10 10 export * from "./entity" 11 + export * from "./validation"
+20 -4
src/data/units.ts
··· 42 42 export type MetersPerSecond = "MetersPerSecond" 43 43 44 44 // ----------------------------------------------------------------------------- 45 + // Unwrapping 46 + // ----------------------------------------------------------------------------- 47 + 48 + /** 49 + * Extract the raw number from a quantity. 50 + * Use this instead of casting when you need the underlying value. 51 + * 52 + * @example 53 + * ```typescript 54 + * const d = meters(100) 55 + * const raw = unquantity(d) // 100 56 + * ``` 57 + */ 58 + export const unquantity = <U>(q: Quantity<number, U>): number => q as number 59 + 60 + // ----------------------------------------------------------------------------- 45 61 // Constructors 46 62 // ----------------------------------------------------------------------------- 47 63 ··· 106 122 distance: Quantity<number, Meters>, 107 123 time: Quantity<number, Seconds> 108 124 ): Quantity<number, MetersPerSecond> => 109 - ((distance as number) / (time as number)) as Quantity<number, MetersPerSecond> 125 + (unquantity(distance) / unquantity(time)) as Quantity<number, MetersPerSecond> 110 126 111 127 // ----------------------------------------------------------------------------- 112 128 // Same-Unit Arithmetic ··· 123 139 export const addQ = <U>( 124 140 a: Quantity<number, U>, 125 141 b: Quantity<number, U> 126 - ): Quantity<number, U> => ((a as number) + (b as number)) as Quantity<number, U> 142 + ): Quantity<number, U> => (unquantity(a) + unquantity(b)) as Quantity<number, U> 127 143 128 144 /** 129 145 * Subtract two quantities of the same unit. ··· 136 152 export const subQ = <U>( 137 153 a: Quantity<number, U>, 138 154 b: Quantity<number, U> 139 - ): Quantity<number, U> => ((a as number) - (b as number)) as Quantity<number, U> 155 + ): Quantity<number, U> => (unquantity(a) - unquantity(b)) as Quantity<number, U> 140 156 141 157 /** 142 158 * Scale a quantity by a scalar value. ··· 149 165 export const scaleQ = 150 166 (scalar: number) => 151 167 <U>(q: Quantity<number, U>): Quantity<number, U> => 152 - (scalar * (q as number)) as Quantity<number, U> 168 + (scalar * unquantity(q)) as Quantity<number, U>
+251
src/data/validation.ts
··· 1 + /** 2 + * Validation applicative for error accumulation. 3 + * 4 + * Unlike Result which short-circuits on the first error (monadic), 5 + * Validation accumulates all errors (applicative). Use this for 6 + * form validation, parsing, or any scenario where you want all errors. 7 + * 8 + * @example 9 + * ```typescript 10 + * const validateName = (name: string): Validation<string, string> => 11 + * name.length > 0 ? valid(name) : invalid(["Name required"]) 12 + * 13 + * const validateAge = (age: number): Validation<number, string> => 14 + * age >= 0 ? valid(age) : invalid(["Age must be non-negative"]) 15 + * 16 + * // Accumulates errors from both validations 17 + * pipe( 18 + * valid((name: string) => (age: number) => ({ name, age })), 19 + * apValidation(validateName("")), 20 + * apValidation(validateAge(-1)) 21 + * ) // Invalid(["Name required", "Age must be non-negative"]) 22 + * ``` 23 + * 24 + * @module data/validation 25 + */ 26 + 27 + import { ok, err, type Result } from "../prelude/result" 28 + 29 + // ----------------------------------------------------------------------------- 30 + // Types 31 + // ----------------------------------------------------------------------------- 32 + 33 + /** 34 + * Valid variant of Validation. Contains a successful value. 35 + */ 36 + export type Valid<A> = { readonly _tag: "Valid"; readonly value: A } 37 + 38 + /** 39 + * Invalid variant of Validation. Contains accumulated errors. 40 + */ 41 + export type Invalid<E> = { readonly _tag: "Invalid"; readonly errors: readonly E[] } 42 + 43 + /** 44 + * Validation<A, E> - Either a valid value A or accumulated errors E[]. 45 + * 46 + * Unlike Result, Validation accumulates errors via apValidation. 47 + * Use this when you want to collect all validation errors, not just the first. 48 + * 49 + * @template A - The success value type 50 + * @template E - The error type (accumulated in an array) 51 + */ 52 + export type Validation<A, E> = Valid<A> | Invalid<E> 53 + 54 + // ----------------------------------------------------------------------------- 55 + // Constructors 56 + // ----------------------------------------------------------------------------- 57 + 58 + /** 59 + * Create a valid Validation. 60 + * 61 + * @example 62 + * ```typescript 63 + * const v = valid(42) 64 + * // Type: Valid<number> 65 + * ``` 66 + */ 67 + export const valid = <A>(value: A): Valid<A> => ({ _tag: "Valid", value }) 68 + 69 + /** 70 + * Create an invalid Validation with errors. 71 + * 72 + * @example 73 + * ```typescript 74 + * const v = invalid(["Field required", "Must be positive"]) 75 + * // Type: Invalid<string> 76 + * ``` 77 + */ 78 + export const invalid = <E>(errors: readonly E[]): Invalid<E> => ({ _tag: "Invalid", errors }) 79 + 80 + /** 81 + * Create an invalid Validation from a single error. 82 + * 83 + * @example 84 + * ```typescript 85 + * const v = invalidOne("Field required") 86 + * // Type: Invalid<string> 87 + * ``` 88 + */ 89 + export const invalidOne = <E>(error: E): Invalid<E> => ({ _tag: "Invalid", errors: [error] }) 90 + 91 + // ----------------------------------------------------------------------------- 92 + // Type Guards 93 + // ----------------------------------------------------------------------------- 94 + 95 + /** 96 + * Type guard to check if a Validation is valid. 97 + */ 98 + export const isValid = <A, E>(v: Validation<A, E>): v is Valid<A> => v._tag === "Valid" 99 + 100 + /** 101 + * Type guard to check if a Validation is invalid. 102 + */ 103 + export const isInvalid = <A, E>(v: Validation<A, E>): v is Invalid<E> => v._tag === "Invalid" 104 + 105 + // ----------------------------------------------------------------------------- 106 + // Functor 107 + // ----------------------------------------------------------------------------- 108 + 109 + /** 110 + * Transform the value inside a valid Validation. 111 + * Invalid passes through unchanged. 112 + * 113 + * @example 114 + * ```typescript 115 + * pipe(valid(10), mapValidation(n => n * 2)) // Valid(20) 116 + * pipe(invalid(["oops"]), mapValidation(n => n * 2)) // Invalid(["oops"]) 117 + * ``` 118 + */ 119 + export const mapValidation = <A, B, E>(f: (a: A) => B) => 120 + (v: Validation<A, E>): Validation<B, E> => 121 + v._tag === "Valid" ? valid(f(v.value)) : v 122 + 123 + // ----------------------------------------------------------------------------- 124 + // Applicative (Error Accumulation) 125 + // ----------------------------------------------------------------------------- 126 + 127 + /** 128 + * Apply a validation containing a function to a validation containing a value. 129 + * If both are invalid, errors are accumulated. 130 + * 131 + * This is the key difference from Result - errors are collected, not short-circuited. 132 + * 133 + * @example 134 + * ```typescript 135 + * // Both valid - applies function 136 + * pipe( 137 + * valid((x: number) => x * 2), 138 + * apValidation(valid(10)) 139 + * ) // Valid(20) 140 + * 141 + * // One invalid - returns that error 142 + * pipe( 143 + * valid((x: number) => x * 2), 144 + * apValidation(invalid(["error"])) 145 + * ) // Invalid(["error"]) 146 + * 147 + * // Both invalid - accumulates errors 148 + * pipe( 149 + * invalid(["error1"]), 150 + * apValidation(invalid(["error2"])) 151 + * ) // Invalid(["error1", "error2"]) 152 + * ``` 153 + */ 154 + export const apValidation = <A, B, E>(va: Validation<A, E>) => 155 + (vf: Validation<(a: A) => B, E>): Validation<B, E> => 156 + vf._tag === "Valid" && va._tag === "Valid" 157 + ? valid(vf.value(va.value)) 158 + : vf._tag === "Invalid" && va._tag === "Invalid" 159 + ? invalid([...vf.errors, ...va.errors]) 160 + : vf._tag === "Invalid" ? vf : va as Invalid<E> 161 + 162 + // ----------------------------------------------------------------------------- 163 + // Conversions 164 + // ----------------------------------------------------------------------------- 165 + 166 + /** 167 + * Convert a Result to a Validation. 168 + * Err becomes Invalid with a single error. 169 + * 170 + * @example 171 + * ```typescript 172 + * fromResult(ok(42)) // Valid(42) 173 + * fromResult(err("oops")) // Invalid(["oops"]) 174 + * ``` 175 + */ 176 + export const fromResult = <T, E>(result: Result<T, E>): Validation<T, E> => 177 + result._tag === "Ok" ? valid(result.value) : invalid([result.error]) 178 + 179 + /** 180 + * Convert a Validation to a Result. 181 + * Invalid returns only the first error (loses accumulated errors). 182 + * 183 + * @example 184 + * ```typescript 185 + * toResult(valid(42)) // Ok(42) 186 + * toResult(invalid(["err1", "err2"])) // Err("err1") 187 + * ``` 188 + */ 189 + export const toResult = <A, E>(v: Validation<A, E>): Result<A, E> => 190 + v._tag === "Valid" ? ok(v.value) : err(v.errors[0]!) 191 + 192 + /** 193 + * Convert a Validation to a Result, preserving all errors. 194 + * 195 + * @example 196 + * ```typescript 197 + * toResultAll(valid(42)) // Ok(42) 198 + * toResultAll(invalid(["err1", "err2"])) // Err(["err1", "err2"]) 199 + * ``` 200 + */ 201 + export const toResultAll = <A, E>(v: Validation<A, E>): Result<A, readonly E[]> => 202 + v._tag === "Valid" ? ok(v.value) : err(v.errors) 203 + 204 + // ----------------------------------------------------------------------------- 205 + // Pattern Matching 206 + // ----------------------------------------------------------------------------- 207 + 208 + /** 209 + * Pattern match on a Validation, handling both Valid and Invalid cases. 210 + * 211 + * @example 212 + * ```typescript 213 + * matchValidation( 214 + * value => `Valid: ${value}`, 215 + * errors => `Invalid: ${errors.join(", ")}` 216 + * )(validation) 217 + * ``` 218 + */ 219 + export const matchValidation = <A, E, R>( 220 + onValid: (value: A) => R, 221 + onInvalid: (errors: readonly E[]) => R 222 + ) => (v: Validation<A, E>): R => 223 + v._tag === "Valid" ? onValid(v.value) : onInvalid(v.errors) 224 + 225 + // ----------------------------------------------------------------------------- 226 + // Utilities 227 + // ----------------------------------------------------------------------------- 228 + 229 + /** 230 + * Get the errors from an Invalid, or empty array if Valid. 231 + * 232 + * @example 233 + * ```typescript 234 + * getErrors(valid(42)) // [] 235 + * getErrors(invalid(["err1", "err2"])) // ["err1", "err2"] 236 + * ``` 237 + */ 238 + export const getErrors = <A, E>(v: Validation<A, E>): readonly E[] => 239 + v._tag === "Invalid" ? v.errors : [] 240 + 241 + /** 242 + * Get the value from a Valid, or undefined if Invalid. 243 + * 244 + * @example 245 + * ```typescript 246 + * getValue(valid(42)) // 42 247 + * getValue(invalid(["oops"])) // undefined 248 + * ``` 249 + */ 250 + export const getValue = <A, E>(v: Validation<A, E>): A | undefined => 251 + v._tag === "Valid" ? v.value : undefined
+74 -4
src/effect/combinators.ts
··· 1 1 import { pipe } from "../prelude/compose" 2 2 import { some, none, matchOption, type Option } from "../prelude/option" 3 3 import { succeed, async, flatMap, mapEff, fork, foldEff, type Eff, type Fiber } from "./eff" 4 - import { success, type Exit } from "./exit" 4 + import { success, failure, interrupted, type Exit } from "./exit" 5 5 6 6 // === TapEff - side effect that returns original value === 7 7 ··· 120 120 } else if (exit._tag === "Failure") { 121 121 done = true 122 122 interruptAll() 123 - resume(exit as unknown as Exit<readonly A[], E>) 123 + resume(failure(exit.error)) 124 124 } else { 125 - // Interrupted - treat as failure 125 + // Interrupted - propagate interruption 126 126 done = true 127 127 interruptAll() 128 - resume(exit as unknown as Exit<readonly A[], E>) 128 + resume(interrupted(exit.by)) 129 129 } 130 130 }) 131 131 }) ··· 149 149 ) 150 150 ) 151 151 ) 152 + 153 + // === Traverse / Sequence === 154 + 155 + /** 156 + * Apply an effect-producing function to each element and collect results sequentially. 157 + * Short-circuits on first error. 158 + * 159 + * @example 160 + * ```typescript 161 + * const fetchUser = (id: string): Eff<User, Error, HttpClient> => ... 162 + * 163 + * pipe( 164 + * ["1", "2", "3"], 165 + * traverse(fetchUser) 166 + * ) // Eff<readonly User[], Error, HttpClient> 167 + * ``` 168 + */ 169 + export const traverse = <A, B, E, R>(f: (a: A) => Eff<B, E, R>) => 170 + (as: readonly A[]): Eff<readonly B[], E, R> => 171 + as.length === 0 172 + ? succeed([]) 173 + : pipe( 174 + f(as[0]!), 175 + flatMap(b => pipe( 176 + traverse(f)(as.slice(1)), 177 + mapEff(bs => [b, ...bs] as readonly B[]) 178 + )) 179 + ) 180 + 181 + /** 182 + * Sequence a collection of effects into an effect of a collection. 183 + * Runs effects sequentially, short-circuits on first error. 184 + * 185 + * @example 186 + * ```typescript 187 + * const effects = [fetchUser("1"), fetchUser("2"), fetchUser("3")] 188 + * const allUsers = sequence(effects) // Eff<readonly User[], Error, HttpClient> 189 + * ``` 190 + */ 191 + export const sequence = <A, E, R>( 192 + effs: readonly Eff<A, E, R>[] 193 + ): Eff<readonly A[], E, R> => 194 + traverse((eff: Eff<A, E, R>) => eff)(effs) 195 + 196 + /** 197 + * Like traverse but runs all effects in parallel. 198 + * Fails fast on first error, interrupting remaining fibers. 199 + * 200 + * @example 201 + * ```typescript 202 + * pipe( 203 + * ["1", "2", "3"], 204 + * traversePar(fetchUser) 205 + * ) // All fetches run concurrently 206 + * ``` 207 + */ 208 + export const traversePar = <A, B, E, R>(f: (a: A) => Eff<B, E, R>) => 209 + (as: readonly A[]): Eff<readonly B[], E, R> => all(as.map(f)) 210 + 211 + /** 212 + * Like sequence but runs all effects in parallel. 213 + * Alias for `all`. 214 + * 215 + * @example 216 + * ```typescript 217 + * const effects = [fetchUser("1"), fetchUser("2"), fetchUser("3")] 218 + * const allUsers = sequencePar(effects) // All run concurrently 219 + * ``` 220 + */ 221 + export const sequencePar = all 152 222 153 223 // === Timeout === 154 224
+25 -1
src/effect/exit.ts
··· 41 41 Interrupted: ({ by }) => onInterrupted(by) 42 42 }) 43 43 44 + /** 45 + * Transform both success and failure values of an Exit (Bifunctor). 46 + * Interrupted exits pass through unchanged. 47 + * 48 + * @example 49 + * ```typescript 50 + * pipe( 51 + * success(10), 52 + * bimapExit( 53 + * n => n * 2, // transform Success 54 + * e => e.toUpperCase() // transform Failure 55 + * ) 56 + * ) // Success(20) 57 + * ``` 58 + */ 59 + export const bimapExit = <A, B, E, F>( 60 + onSuccess: (a: A) => B, 61 + onFailure: (e: E) => F 62 + ) => (exit: Exit<A, E>): Exit<B, F> => 63 + exit._tag === "Success" ? success(onSuccess(exit.value)) : 64 + exit._tag === "Failure" ? failure(onFailure(exit.error)) : 65 + interrupted(exit.by) 66 + 44 67 // Namespace for convenient access (using original naming convention) 45 68 export const Exit = { 46 69 succeed: success, ··· 52 75 isSuccess, 53 76 isFailure, 54 77 isInterrupted, 55 - match: matchExit 78 + match: matchExit, 79 + bimap: bimapExit 56 80 }
+1
src/prelude/index.ts
··· 3 3 export * from "./option" 4 4 export * from "./match" 5 5 export * from "./brand" 6 + export * from "./typeclasses"
+29
src/prelude/result.ts
··· 124 124 result._tag === "Err" ? err(f(result.error)) : result 125 125 126 126 /** 127 + * Transform both success and error values of a Result (Bifunctor). 128 + * Applies onOk to Ok values, onErr to Err values. 129 + * 130 + * @example 131 + * ```typescript 132 + * pipe( 133 + * ok(10), 134 + * bimap( 135 + * n => n * 2, // transform Ok 136 + * e => e.toUpperCase() // transform Err 137 + * ) 138 + * ) // Ok(20) 139 + * 140 + * pipe( 141 + * err("oops"), 142 + * bimap( 143 + * n => n * 2, 144 + * e => e.toUpperCase() 145 + * ) 146 + * ) // Err("OOPS") 147 + * ``` 148 + */ 149 + export const bimap = <T, U, E, F>( 150 + onOk: (t: T) => U, 151 + onErr: (e: E) => F 152 + ) => (result: Result<T, E>): Result<U, F> => 153 + result._tag === "Ok" ? ok(onOk(result.value)) : err(onErr(result.error)) 154 + 155 + /** 127 156 * Chain Result-returning operations. 128 157 * If the input is Ok, runs the function. If Err, passes through unchanged. 129 158 *
+265
src/prelude/typeclasses.ts
··· 1 + /** 2 + * Type class interfaces for educational purposes. 3 + * 4 + * TypeScript lacks higher-kinded types (HKT), so we can't express type classes 5 + * generically like Haskell. Instead, we show the pattern with concrete interfaces 6 + * and demonstrate how Result/Option satisfy these contracts conceptually. 7 + * 8 + * Laws (not enforced by types, but should be verified in tests): 9 + * 10 + * Functor: 11 + * - Identity: map(id) === id 12 + * - Composition: map(f . g) === map(f) . map(g) 13 + * 14 + * Applicative: 15 + * - Identity: ap(of(id))(v) === v 16 + * - Homomorphism: ap(of(f))(of(x)) === of(f(x)) 17 + * 18 + * Monad: 19 + * - Left identity: flatMap(f)(of(a)) === f(a) 20 + * - Right identity: flatMap(of)(m) === m 21 + * - Associativity: flatMap(g)(flatMap(f)(m)) === flatMap(x => flatMap(g)(f(x)))(m) 22 + * 23 + * Bifunctor: 24 + * - Identity: bimap(id, id) === id 25 + * - Composition: bimap(f . g, h . i) === bimap(f, h) . bimap(g, i) 26 + * 27 + * @module prelude/typeclasses 28 + */ 29 + 30 + import { 31 + ok, mapResult, chainResult, bimap, 32 + type Result 33 + } from "./result" 34 + import { 35 + some, mapOption, flatMapOption, 36 + type Option 37 + } from "./option" 38 + 39 + // ----------------------------------------------------------------------------- 40 + // Type Class Interfaces (Educational) 41 + // ----------------------------------------------------------------------------- 42 + 43 + // ----------------------------------------------------------------------------- 44 + // Conceptual Type Class Interfaces 45 + // ----------------------------------------------------------------------------- 46 + // TypeScript lacks higher-kinded types (HKT), so we cannot express these 47 + // interfaces generically like "interface Functor<F[_]>". Instead, we document 48 + // the pattern conceptually. The instance objects below show concrete examples. 49 + 50 + /** 51 + * Functor - types that can be mapped over. 52 + * 53 + * The fundamental operation for transforming values in a context. 54 + * Every Applicative and Monad is also a Functor. 55 + * 56 + * Signature (conceptual): 57 + * map :: (a -> b) -> F a -> F b 58 + * 59 + * Laws: 60 + * Identity: map(id) === id 61 + * Composition: map(f . g) === map(f) . map(g) 62 + * 63 + * @example Intuition 64 + * - Array: map transforms each element 65 + * - Option: map transforms the value if present 66 + * - Result: map transforms the success value 67 + * - Promise: map (then) transforms the resolved value 68 + */ 69 + // Conceptual: interface Functor<F> { map: <A, B>(f: (a: A) => B) => (fa: F<A>) => F<B> } 70 + 71 + /** 72 + * Applicative - Functors that support applying wrapped functions. 73 + * 74 + * Enables combining independent computations. Unlike Monad, Applicative 75 + * computations don't depend on previous results, enabling parallelism. 76 + * 77 + * Signature (conceptual): 78 + * of :: a -> F a 79 + * ap :: F a -> F (a -> b) -> F b 80 + * 81 + * Laws: 82 + * Identity: ap(of(id))(v) === v 83 + * Homomorphism: ap(of(f))(of(x)) === of(f(x)) 84 + * 85 + * @example Intuition 86 + * - Validation: ap accumulates errors from independent validations 87 + * - Parser: ap combines independent parsers 88 + * - Async: ap runs independent async operations in parallel 89 + */ 90 + // Conceptual: interface Applicative<F> extends Functor<F> { of: <A>(a: A) => F<A>; ap: <A, B>(fa: F<A>) => (fab: F<(a: A) => B>) => F<B> } 91 + 92 + /** 93 + * Monad - Applicatives that support sequential composition. 94 + * 95 + * Enables computations where each step depends on the previous result. 96 + * The quintessential abstraction for managing effects. 97 + * 98 + * Signature (conceptual): 99 + * flatMap :: (a -> F b) -> F a -> F b 100 + * 101 + * Laws: 102 + * Left identity: flatMap(f)(of(a)) === f(a) 103 + * Right identity: flatMap(of)(m) === m 104 + * Associativity: flatMap(g)(flatMap(f)(m)) === flatMap(x => flatMap(g)(f(x)))(m) 105 + * 106 + * @example Intuition 107 + * - Option: flatMap chains operations that might return nothing 108 + * - Result: flatMap chains operations that might fail 109 + * - Eff: flatMap sequences effectful computations 110 + */ 111 + // Conceptual: interface Monad<F> extends Applicative<F> { flatMap: <A, B>(f: (a: A) => F<B>) => (fa: F<A>) => F<B> } 112 + 113 + /** 114 + * Bifunctor - types with two mappable type parameters. 115 + * 116 + * Like Functor but can transform two independent type parameters. 117 + * Result<T, E> is the canonical example - transform success OR error. 118 + * 119 + * Signature (conceptual): 120 + * bimap :: (a -> b) -> (c -> d) -> F a c -> F b d 121 + * 122 + * Laws: 123 + * Identity: bimap(id, id) === id 124 + * Composition: bimap(f . g, h . i) === bimap(f, h) . bimap(g, i) 125 + * 126 + * @example Intuition 127 + * - Result: bimap transforms Ok value OR Err value 128 + * - Either: bimap transforms Left OR Right 129 + * - Pair: bimap transforms first OR second component 130 + */ 131 + // Conceptual: interface Bifunctor<F> { bimap: <A, B, C, D>(first: (a: A) => B, second: (c: C) => D) => (fa: F<A, C>) => F<B, D> } 132 + 133 + // ----------------------------------------------------------------------------- 134 + // Applicative Functions for Result 135 + // ----------------------------------------------------------------------------- 136 + 137 + /** 138 + * Apply a Result containing a function to a Result containing a value. 139 + * Short-circuits on first error (monadic, not accumulating). 140 + * 141 + * @example 142 + * ```typescript 143 + * pipe( 144 + * ok((x: number) => x * 2), 145 + * apResult(ok(10)) 146 + * ) // Ok(20) 147 + * 148 + * pipe( 149 + * err("no function"), 150 + * apResult(ok(10)) 151 + * ) // Err("no function") 152 + * ``` 153 + */ 154 + export const apResult = <A, E>(ra: Result<A, E>) => 155 + <B>(rf: Result<(a: A) => B, E>): Result<B, E> => 156 + rf._tag === "Ok" && ra._tag === "Ok" 157 + ? ok(rf.value(ra.value)) 158 + : rf._tag === "Err" ? rf : ra as Result<B, E> 159 + 160 + // ----------------------------------------------------------------------------- 161 + // Applicative Functions for Option 162 + // ----------------------------------------------------------------------------- 163 + 164 + /** 165 + * Apply an Option containing a function to an Option containing a value. 166 + * 167 + * @example 168 + * ```typescript 169 + * pipe( 170 + * some((x: number) => x * 2), 171 + * apOption(some(10)) 172 + * ) // Some(20) 173 + * 174 + * pipe( 175 + * none, 176 + * apOption(some(10)) 177 + * ) // None 178 + * ``` 179 + */ 180 + export const apOption = <A>(oa: Option<A>) => 181 + <B>(of_: Option<(a: A) => B>): Option<B> => 182 + of_._tag === "Some" && oa._tag === "Some" 183 + ? some(of_.value(oa.value)) 184 + : { _tag: "None" } 185 + 186 + // ----------------------------------------------------------------------------- 187 + // Instance Objects (Demonstrating the Pattern) 188 + // ----------------------------------------------------------------------------- 189 + 190 + /** 191 + * Result satisfies Functor, Applicative, Monad, and Bifunctor. 192 + * 193 + * Note: Due to TypeScript's type system limitations, these functions 194 + * are typed for specific Result types. In a language with HKT, these 195 + * would be generic over any Result<A, E>. 196 + */ 197 + export const resultInstances = { 198 + // Functor 199 + map: mapResult, 200 + 201 + // Applicative 202 + of: ok, 203 + ap: apResult, 204 + 205 + // Monad 206 + flatMap: chainResult, 207 + 208 + // Bifunctor 209 + bimap 210 + } as const 211 + 212 + /** 213 + * Option satisfies Functor, Applicative, and Monad. 214 + */ 215 + export const optionInstances = { 216 + // Functor 217 + map: mapOption, 218 + 219 + // Applicative 220 + of: some, 221 + ap: apOption, 222 + 223 + // Monad 224 + flatMap: flatMapOption 225 + } as const 226 + 227 + // ----------------------------------------------------------------------------- 228 + // Lift Functions (Applicative Pattern) 229 + // ----------------------------------------------------------------------------- 230 + 231 + /** 232 + * Lift a binary function to work with Results. 233 + * 234 + * @example 235 + * ```typescript 236 + * const add = (a: number, b: number) => a + b 237 + * const addResults = liftA2Result(add) 238 + * 239 + * addResults(ok(1), ok(2)) // Ok(3) 240 + * addResults(ok(1), err("!")) // Err("!") 241 + * ``` 242 + */ 243 + export const liftA2Result = <A, B, C, E>(f: (a: A, b: B) => C) => 244 + (ra: Result<A, E>, rb: Result<B, E>): Result<C, E> => 245 + ra._tag === "Ok" && rb._tag === "Ok" 246 + ? ok(f(ra.value, rb.value)) 247 + : ra._tag === "Err" ? ra : rb as Result<C, E> 248 + 249 + /** 250 + * Lift a binary function to work with Options. 251 + * 252 + * @example 253 + * ```typescript 254 + * const add = (a: number, b: number) => a + b 255 + * const addOptions = liftA2Option(add) 256 + * 257 + * addOptions(some(1), some(2)) // Some(3) 258 + * addOptions(some(1), none) // None 259 + * ``` 260 + */ 261 + export const liftA2Option = <A, B, C>(f: (a: A, b: B) => C) => 262 + (oa: Option<A>, ob: Option<B>): Option<C> => 263 + oa._tag === "Some" && ob._tag === "Some" 264 + ? some(f(oa.value, ob.value)) 265 + : { _tag: "None" }
+105
tests/effect-concurrency.test.ts
··· 1 1 import { describe, it, expect } from "bun:test" 2 2 import { 3 3 succeed, fail, sleep, fork, join, race, all, timeout, retry, 4 + traverse, sequence, traversePar, sequencePar, 4 5 mapEff, flatMap, pipe, runPromise, runPromiseExit, runFiber, Exit 5 6 } from "../src/index" 6 7 ··· 85 86 setTimeout(() => fiber.interrupt(), 20) 86 87 const exit = await fiber.await() 87 88 expect(Exit.isInterrupted(exit)).toBe(true) 89 + }) 90 + }) 91 + 92 + describe("traverse", () => { 93 + it("applies effect-producing function to each element sequentially", async () => { 94 + const order: number[] = [] 95 + const f = (n: number) => pipe( 96 + sleep(10), 97 + mapEff(() => { 98 + order.push(n) 99 + return n * 2 100 + }) 101 + ) 102 + 103 + const result = await runPromise(pipe([1, 2, 3], traverse(f))) 104 + expect(result).toEqual([2, 4, 6]) 105 + expect(order).toEqual([1, 2, 3]) // Sequential order preserved 106 + }) 107 + 108 + it("short-circuits on first error", async () => { 109 + let executed = 0 110 + const f = (n: number) => { 111 + executed++ 112 + return n === 2 ? fail(`error at ${n}`) : succeed(n * 2) 113 + } 114 + 115 + const exit = await runPromiseExit(pipe([1, 2, 3], traverse(f))) 116 + expect(Exit.isFailure(exit)).toBe(true) 117 + expect(executed).toBe(2) // Stopped at error 118 + }) 119 + 120 + it("returns empty array for empty input", async () => { 121 + const f = (n: number) => succeed(n * 2) 122 + const result = await runPromise(pipe([] as number[], traverse(f))) 123 + expect(result).toEqual([]) 124 + }) 125 + }) 126 + 127 + describe("sequence", () => { 128 + it("collects results from array of effects", async () => { 129 + const effects = [succeed(1), succeed(2), succeed(3)] 130 + const result = await runPromise(sequence(effects)) 131 + expect(result).toEqual([1, 2, 3]) 132 + }) 133 + 134 + it("short-circuits on first error", async () => { 135 + const effects = [succeed(1), fail("boom"), succeed(3)] 136 + const exit = await runPromiseExit(sequence(effects)) 137 + expect(Exit.isFailure(exit)).toBe(true) 138 + }) 139 + 140 + it("runs sequentially", async () => { 141 + const order: string[] = [] 142 + const effects = [ 143 + pipe(sleep(10), mapEff(() => { order.push("a"); return "a" })), 144 + pipe(sleep(10), mapEff(() => { order.push("b"); return "b" })), 145 + ] 146 + await runPromise(sequence(effects)) 147 + expect(order).toEqual(["a", "b"]) 148 + }) 149 + }) 150 + 151 + describe("traversePar", () => { 152 + it("applies function in parallel", async () => { 153 + const start = Date.now() 154 + const f = (n: number) => pipe(sleep(50), mapEff(() => n * 2)) 155 + 156 + const result = await runPromise(pipe([1, 2, 3], traversePar(f))) 157 + const elapsed = Date.now() - start 158 + 159 + expect(result).toEqual([2, 4, 6]) 160 + expect(elapsed).toBeLessThan(150) // Parallel, not 3x50ms 161 + }) 162 + 163 + it("fails fast on first error", async () => { 164 + const f = (n: number) => 165 + n === 2 166 + ? pipe(sleep(10), flatMap(() => fail("error"))) 167 + : pipe(sleep(100), mapEff(() => n)) 168 + 169 + const exit = await runPromiseExit(pipe([1, 2, 3], traversePar(f))) 170 + expect(Exit.isFailure(exit)).toBe(true) 171 + }) 172 + }) 173 + 174 + describe("sequencePar", () => { 175 + it("is an alias for all", async () => { 176 + const effects = [succeed(1), succeed(2), succeed(3)] 177 + const result = await runPromise(sequencePar(effects)) 178 + expect(result).toEqual([1, 2, 3]) 179 + }) 180 + 181 + it("runs in parallel", async () => { 182 + const start = Date.now() 183 + const effects = [ 184 + pipe(sleep(50), mapEff(() => "a")), 185 + pipe(sleep(50), mapEff(() => "b")), 186 + pipe(sleep(50), mapEff(() => "c")), 187 + ] 188 + const result = await runPromise(sequencePar(effects)) 189 + const elapsed = Date.now() - start 190 + 191 + expect(result).toEqual(["a", "b", "c"]) 192 + expect(elapsed).toBeLessThan(150) 88 193 }) 89 194 }) 90 195 })
+100
tests/exit.test.ts
··· 1 + import { describe, it, expect } from "bun:test" 2 + import { Exit, success, failure, interrupted, bimapExit, pipe } from "../src/index" 3 + 4 + describe("Exit", () => { 5 + describe("constructors", () => { 6 + it("success creates Success variant", () => { 7 + const exit = success(42) 8 + expect(exit._tag).toBe("Success") 9 + expect(exit.value).toBe(42) 10 + }) 11 + 12 + it("failure creates Failure variant", () => { 13 + const exit = failure("error") 14 + expect(exit._tag).toBe("Failure") 15 + expect(exit.error).toBe("error") 16 + }) 17 + 18 + it("interrupted creates Interrupted variant", () => { 19 + const exit = interrupted("user") 20 + expect(exit._tag).toBe("Interrupted") 21 + expect(exit.by).toBe("user") 22 + }) 23 + }) 24 + 25 + describe("type guards", () => { 26 + it("isSuccess detects Success", () => { 27 + expect(Exit.isSuccess(success(1))).toBe(true) 28 + expect(Exit.isSuccess(failure("e"))).toBe(false) 29 + expect(Exit.isSuccess(interrupted("x"))).toBe(false) 30 + }) 31 + 32 + it("isFailure detects Failure", () => { 33 + expect(Exit.isFailure(failure("e"))).toBe(true) 34 + expect(Exit.isFailure(success(1))).toBe(false) 35 + expect(Exit.isFailure(interrupted("x"))).toBe(false) 36 + }) 37 + 38 + it("isInterrupted detects Interrupted", () => { 39 + expect(Exit.isInterrupted(interrupted("x"))).toBe(true) 40 + expect(Exit.isInterrupted(success(1))).toBe(false) 41 + expect(Exit.isInterrupted(failure("e"))).toBe(false) 42 + }) 43 + }) 44 + 45 + describe("bimapExit", () => { 46 + it("transforms Success value", () => { 47 + const exit = pipe( 48 + success(10), 49 + bimapExit( 50 + n => n * 2, 51 + (e: string) => e.toUpperCase() 52 + ) 53 + ) 54 + expect(exit).toEqual(success(20)) 55 + }) 56 + 57 + it("transforms Failure error", () => { 58 + const exit = pipe( 59 + failure("oops"), 60 + bimapExit( 61 + (n: number) => n * 2, 62 + e => e.toUpperCase() 63 + ) 64 + ) 65 + expect(exit).toEqual(failure("OOPS")) 66 + }) 67 + 68 + it("passes Interrupted through unchanged", () => { 69 + const exit = pipe( 70 + interrupted("user-cancel"), 71 + bimapExit( 72 + (n: number) => n * 2, 73 + (e: string) => e.toUpperCase() 74 + ) 75 + ) 76 + expect(exit).toEqual(interrupted("user-cancel")) 77 + }) 78 + 79 + it("transforms types correctly", () => { 80 + const exit = pipe( 81 + success(42), 82 + bimapExit( 83 + n => n.toString(), 84 + (e: string) => ({ code: 500, message: e }) 85 + ) 86 + ) 87 + expect(exit).toEqual(success("42")) 88 + }) 89 + }) 90 + 91 + describe("Exit.bimap (namespace)", () => { 92 + it("is available via Exit namespace", () => { 93 + const exit = pipe( 94 + success(5), 95 + Exit.bimap(n => n + 1, (e: string) => e) 96 + ) 97 + expect(exit).toEqual(success(6)) 98 + }) 99 + }) 100 + })
+36 -1
tests/result.test.ts
··· 1 1 import { describe, it, expect } from "bun:test" 2 - import { ok, err, mapResult, chainResult, unwrapOr, tryCatch, pipe } from "../src/index" 2 + import { ok, err, mapResult, mapErr, chainResult, unwrapOr, tryCatch, bimap, pipe } from "../src/index" 3 3 4 4 describe("Result", () => { 5 5 describe("constructors", () => { ··· 62 62 it("catches exceptions", () => { 63 63 const result = tryCatch(() => JSON.parse("invalid")) 64 64 expect(result._tag).toBe("Err") 65 + }) 66 + }) 67 + 68 + describe("bimap", () => { 69 + it("transforms Ok value with first function", () => { 70 + const result = pipe( 71 + ok(10), 72 + bimap( 73 + n => n * 2, 74 + (e: string) => e.toUpperCase() 75 + ) 76 + ) 77 + expect(result).toEqual(ok(20)) 78 + }) 79 + 80 + it("transforms Err value with second function", () => { 81 + const result = pipe( 82 + err("oops"), 83 + bimap( 84 + (n: number) => n * 2, 85 + e => e.toUpperCase() 86 + ) 87 + ) 88 + expect(result).toEqual(err("OOPS")) 89 + }) 90 + 91 + it("transforms types correctly", () => { 92 + const result = pipe( 93 + ok(42), 94 + bimap( 95 + n => n.toString(), 96 + (e: string) => ({ message: e }) 97 + ) 98 + ) 99 + expect(result).toEqual(ok("42")) 65 100 }) 66 101 }) 67 102 })
+224
tests/typeclasses.test.ts
··· 1 + import { describe, it, expect } from "bun:test" 2 + import { 3 + ok, err, some, none, pipe, 4 + apResult, apOption, liftA2Result, liftA2Option, 5 + resultInstances, optionInstances 6 + } from "../src/index" 7 + 8 + describe("Type Classes", () => { 9 + describe("apResult", () => { 10 + it("applies function in Ok to value in Ok", () => { 11 + const rf = ok((x: number) => x * 2) 12 + const ra = ok(10) 13 + const result = pipe(rf, apResult(ra)) 14 + expect(result).toEqual(ok(20)) 15 + }) 16 + 17 + it("returns Err when function is Err", () => { 18 + const rf = err("no function") 19 + const ra = ok(10) 20 + const result = pipe(rf, apResult(ra)) 21 + expect(result).toEqual(err("no function")) 22 + }) 23 + 24 + it("returns Err when value is Err", () => { 25 + const rf = ok((x: number) => x * 2) 26 + const ra = err("no value") 27 + const result = pipe(rf, apResult(ra)) 28 + expect(result).toEqual(err("no value")) 29 + }) 30 + 31 + it("returns first Err when both are Err (short-circuit)", () => { 32 + const rf = err("func error") 33 + const ra = err("value error") 34 + const result = pipe(rf, apResult(ra)) 35 + expect(result).toEqual(err("func error")) 36 + }) 37 + 38 + it("works with curried functions", () => { 39 + const add = (a: number) => (b: number) => a + b 40 + const result = pipe( 41 + ok(add), 42 + apResult(ok(10)), 43 + apResult(ok(5)) 44 + ) 45 + expect(result).toEqual(ok(15)) 46 + }) 47 + }) 48 + 49 + describe("apOption", () => { 50 + it("applies function in Some to value in Some", () => { 51 + const of_ = some((x: number) => x * 2) 52 + const oa = some(10) 53 + const result = pipe(of_, apOption(oa)) 54 + expect(result).toEqual(some(20)) 55 + }) 56 + 57 + it("returns None when function is None", () => { 58 + const of_ = none as typeof none & { _tag: "None" } 59 + const oa = some(10) 60 + const result = pipe(of_, apOption(oa)) 61 + expect(result._tag).toBe("None") 62 + }) 63 + 64 + it("returns None when value is None", () => { 65 + const of_ = some((x: number) => x * 2) 66 + const oa = none 67 + const result = pipe(of_, apOption(oa)) 68 + expect(result._tag).toBe("None") 69 + }) 70 + 71 + it("works with curried functions", () => { 72 + const add = (a: number) => (b: number) => a + b 73 + const result = pipe( 74 + some(add), 75 + apOption(some(10)), 76 + apOption(some(5)) 77 + ) 78 + expect(result).toEqual(some(15)) 79 + }) 80 + }) 81 + 82 + describe("liftA2Result", () => { 83 + it("lifts binary function to work with Results", () => { 84 + const add = (a: number, b: number) => a + b 85 + const addResults = liftA2Result(add) 86 + 87 + expect(addResults(ok(1), ok(2))).toEqual(ok(3)) 88 + }) 89 + 90 + it("returns Err when first is Err", () => { 91 + const add = (a: number, b: number) => a + b 92 + const addResults = liftA2Result(add) 93 + 94 + expect(addResults(err("first"), ok(2))).toEqual(err("first")) 95 + }) 96 + 97 + it("returns Err when second is Err", () => { 98 + const add = (a: number, b: number) => a + b 99 + const addResults = liftA2Result(add) 100 + 101 + expect(addResults(ok(1), err("second"))).toEqual(err("second")) 102 + }) 103 + 104 + it("returns first Err when both are Err", () => { 105 + const add = (a: number, b: number) => a + b 106 + const addResults = liftA2Result(add) 107 + 108 + expect(addResults(err("first"), err("second"))).toEqual(err("first")) 109 + }) 110 + }) 111 + 112 + describe("liftA2Option", () => { 113 + it("lifts binary function to work with Options", () => { 114 + const add = (a: number, b: number) => a + b 115 + const addOptions = liftA2Option(add) 116 + 117 + expect(addOptions(some(1), some(2))).toEqual(some(3)) 118 + }) 119 + 120 + it("returns None when first is None", () => { 121 + const add = (a: number, b: number) => a + b 122 + const addOptions = liftA2Option(add) 123 + 124 + expect(addOptions(none, some(2))._tag).toBe("None") 125 + }) 126 + 127 + it("returns None when second is None", () => { 128 + const add = (a: number, b: number) => a + b 129 + const addOptions = liftA2Option(add) 130 + 131 + expect(addOptions(some(1), none)._tag).toBe("None") 132 + }) 133 + }) 134 + 135 + describe("resultInstances", () => { 136 + it("has map function", () => { 137 + const result = pipe(ok(5), resultInstances.map(x => x * 2)) 138 + expect(result).toEqual(ok(10)) 139 + }) 140 + 141 + it("has of function (alias for ok)", () => { 142 + expect(resultInstances.of(42)).toEqual(ok(42)) 143 + }) 144 + 145 + it("has ap function", () => { 146 + const result = pipe(ok((x: number) => x + 1), resultInstances.ap(ok(5))) 147 + expect(result).toEqual(ok(6)) 148 + }) 149 + 150 + it("has flatMap function", () => { 151 + const result = pipe(ok(5), resultInstances.flatMap(x => ok(x * 2))) 152 + expect(result).toEqual(ok(10)) 153 + }) 154 + 155 + it("has bimap function", () => { 156 + const result = pipe( 157 + ok(5), 158 + resultInstances.bimap(x => x * 2, (e: string) => e.toUpperCase()) 159 + ) 160 + expect(result).toEqual(ok(10)) 161 + }) 162 + }) 163 + 164 + describe("optionInstances", () => { 165 + it("has map function", () => { 166 + const result = pipe(some(5), optionInstances.map(x => x * 2)) 167 + expect(result).toEqual(some(10)) 168 + }) 169 + 170 + it("has of function (alias for some)", () => { 171 + expect(optionInstances.of(42)).toEqual(some(42)) 172 + }) 173 + 174 + it("has ap function", () => { 175 + const result = pipe(some((x: number) => x + 1), optionInstances.ap(some(5))) 176 + expect(result).toEqual(some(6)) 177 + }) 178 + 179 + it("has flatMap function", () => { 180 + const result = pipe(some(5), optionInstances.flatMap(x => some(x * 2))) 181 + expect(result).toEqual(some(10)) 182 + }) 183 + }) 184 + 185 + describe("Functor laws (Result)", () => { 186 + it("identity: map(id) === id", () => { 187 + const id = <T>(x: T) => x 188 + const original = ok(42) 189 + expect(pipe(original, resultInstances.map(id))).toEqual(original) 190 + }) 191 + 192 + it("composition: map(f . g) === map(f) . map(g)", () => { 193 + const f = (x: number) => x * 2 194 + const g = (x: number) => x + 1 195 + const fg = (x: number) => f(g(x)) 196 + 197 + const original = ok(5) 198 + const left = pipe(original, resultInstances.map(fg)) 199 + const right = pipe(original, resultInstances.map(g), resultInstances.map(f)) 200 + 201 + expect(left).toEqual(right) 202 + }) 203 + }) 204 + 205 + describe("Monad laws (Result)", () => { 206 + it("left identity: flatMap(f)(of(a)) === f(a)", () => { 207 + const f = (x: number) => ok(x * 2) 208 + const a = 5 209 + 210 + const left = pipe(resultInstances.of(a), resultInstances.flatMap(f)) 211 + const right = f(a) 212 + 213 + expect(left).toEqual(right) 214 + }) 215 + 216 + it("right identity: flatMap(of)(m) === m", () => { 217 + const m = ok(42) 218 + 219 + const result = pipe(m, resultInstances.flatMap(resultInstances.of)) 220 + 221 + expect(result).toEqual(m) 222 + }) 223 + }) 224 + })
+19 -1
tests/units.test.ts
··· 1 1 import { describe, it, expect } from "bun:test" 2 - import { meters, seconds, kilograms, velocity, addQ, subQ, scaleQ } from "../src/index" 2 + import { meters, seconds, kilograms, velocity, addQ, subQ, scaleQ, unquantity } from "../src/index" 3 3 4 4 describe("Units", () => { 5 5 describe("quantity constructors", () => { ··· 37 37 it("scaleQ multiplies by scalar", () => { 38 38 const result = scaleQ(3)(meters(10)) 39 39 expect(result as number).toBe(30) 40 + }) 41 + }) 42 + 43 + describe("unquantity", () => { 44 + it("extracts raw number from quantity", () => { 45 + const d = meters(100) 46 + expect(unquantity(d)).toBe(100) 47 + }) 48 + 49 + it("works with different unit types", () => { 50 + expect(unquantity(meters(50))).toBe(50) 51 + expect(unquantity(seconds(9.58))).toBe(9.58) 52 + expect(unquantity(kilograms(75))).toBe(75) 53 + }) 54 + 55 + it("works with derived quantities", () => { 56 + const v = velocity(meters(100), seconds(10)) 57 + expect(unquantity(v)).toBe(10) 40 58 }) 41 59 }) 42 60 })
+206
tests/validation.test.ts
··· 1 + import { describe, it, expect } from "bun:test" 2 + import { 3 + valid, invalid, invalidOne, isValid, isInvalid, 4 + mapValidation, apValidation, 5 + fromResult, toResult, toResultAll, 6 + matchValidation, getErrors, getValue, 7 + ok, err, pipe 8 + } from "../src/index" 9 + 10 + describe("Validation", () => { 11 + describe("constructors", () => { 12 + it("valid creates Valid variant", () => { 13 + const v = valid(42) 14 + expect(v._tag).toBe("Valid") 15 + expect(v.value).toBe(42) 16 + }) 17 + 18 + it("invalid creates Invalid variant with errors array", () => { 19 + const v = invalid(["error1", "error2"]) 20 + expect(v._tag).toBe("Invalid") 21 + expect(v.errors).toEqual(["error1", "error2"]) 22 + }) 23 + 24 + it("invalidOne creates Invalid with single error", () => { 25 + const v = invalidOne("single error") 26 + expect(v._tag).toBe("Invalid") 27 + expect(v.errors).toEqual(["single error"]) 28 + }) 29 + }) 30 + 31 + describe("type guards", () => { 32 + it("isValid detects Valid", () => { 33 + expect(isValid(valid(1))).toBe(true) 34 + expect(isValid(invalid(["e"]))).toBe(false) 35 + }) 36 + 37 + it("isInvalid detects Invalid", () => { 38 + expect(isInvalid(invalid(["e"]))).toBe(true) 39 + expect(isInvalid(valid(1))).toBe(false) 40 + }) 41 + }) 42 + 43 + describe("mapValidation", () => { 44 + it("transforms Valid value", () => { 45 + const result = pipe(valid(10), mapValidation(n => n * 2)) 46 + expect(result).toEqual(valid(20)) 47 + }) 48 + 49 + it("passes Invalid through unchanged", () => { 50 + const result = pipe(invalid(["err"]), mapValidation((n: number) => n * 2)) 51 + expect(result).toEqual(invalid(["err"])) 52 + }) 53 + }) 54 + 55 + describe("apValidation - error accumulation", () => { 56 + it("applies function when both valid", () => { 57 + const vf = valid((x: number) => x * 2) 58 + const va = valid(10) 59 + const result = pipe(vf, apValidation(va)) 60 + expect(result).toEqual(valid(20)) 61 + }) 62 + 63 + it("returns error when function is invalid", () => { 64 + const vf = invalid<string>(["no function"]) 65 + const va = valid(10) 66 + const result = pipe(vf, apValidation(va)) 67 + expect(result).toEqual(invalid(["no function"])) 68 + }) 69 + 70 + it("returns error when value is invalid", () => { 71 + const vf = valid((x: number) => x * 2) 72 + const va = invalid<string>(["no value"]) 73 + const result = pipe(vf, apValidation(va)) 74 + expect(result).toEqual(invalid(["no value"])) 75 + }) 76 + 77 + it("accumulates errors when both invalid", () => { 78 + const vf = invalid<string>(["error1"]) 79 + const va = invalid<string>(["error2"]) 80 + const result = pipe(vf, apValidation(va)) 81 + expect(result).toEqual(invalid(["error1", "error2"])) 82 + }) 83 + 84 + it("accumulates multiple errors from multiple validations", () => { 85 + type Person = { name: string; age: number; email: string } 86 + const makePerson = (name: string) => (age: number) => (email: string): Person => 87 + ({ name, age, email }) 88 + 89 + const validateName = (name: string) => 90 + name.length > 0 ? valid(name) : invalid(["Name required"]) 91 + 92 + const validateAge = (age: number) => 93 + age >= 0 ? valid(age) : invalid(["Age must be non-negative"]) 94 + 95 + const validateEmail = (email: string) => 96 + email.includes("@") ? valid(email) : invalid(["Invalid email"]) 97 + 98 + // All invalid - accumulates all errors 99 + const result = pipe( 100 + valid(makePerson), 101 + apValidation(validateName("")), 102 + apValidation(validateAge(-5)), 103 + apValidation(validateEmail("bad")) 104 + ) 105 + 106 + expect(result).toEqual(invalid([ 107 + "Name required", 108 + "Age must be non-negative", 109 + "Invalid email" 110 + ])) 111 + }) 112 + 113 + it("returns valid when all validations pass", () => { 114 + type Person = { name: string; age: number } 115 + const makePerson = (name: string) => (age: number): Person => ({ name, age }) 116 + 117 + const validateName = (name: string) => 118 + name.length > 0 ? valid(name) : invalid(["Name required"]) 119 + 120 + const validateAge = (age: number) => 121 + age >= 0 ? valid(age) : invalid(["Age must be non-negative"]) 122 + 123 + const result = pipe( 124 + valid(makePerson), 125 + apValidation(validateName("Alice")), 126 + apValidation(validateAge(30)) 127 + ) 128 + 129 + expect(result).toEqual(valid({ name: "Alice", age: 30 })) 130 + }) 131 + }) 132 + 133 + describe("fromResult", () => { 134 + it("converts Ok to Valid", () => { 135 + expect(fromResult(ok(42))).toEqual(valid(42)) 136 + }) 137 + 138 + it("converts Err to Invalid with single error", () => { 139 + expect(fromResult(err("oops"))).toEqual(invalid(["oops"])) 140 + }) 141 + }) 142 + 143 + describe("toResult", () => { 144 + it("converts Valid to Ok", () => { 145 + expect(toResult(valid(42))).toEqual(ok(42)) 146 + }) 147 + 148 + it("converts Invalid to Err with first error", () => { 149 + expect(toResult(invalid(["err1", "err2"]))).toEqual(err("err1")) 150 + }) 151 + }) 152 + 153 + describe("toResultAll", () => { 154 + it("converts Valid to Ok", () => { 155 + expect(toResultAll(valid(42))).toEqual(ok(42)) 156 + }) 157 + 158 + it("converts Invalid to Err with all errors", () => { 159 + expect(toResultAll(invalid(["err1", "err2"]))).toEqual(err(["err1", "err2"])) 160 + }) 161 + }) 162 + 163 + describe("matchValidation", () => { 164 + it("calls onValid for Valid", () => { 165 + const result = pipe( 166 + valid(42), 167 + matchValidation( 168 + v => `value: ${v}`, 169 + e => `errors: ${e.join(", ")}` 170 + ) 171 + ) 172 + expect(result).toBe("value: 42") 173 + }) 174 + 175 + it("calls onInvalid for Invalid", () => { 176 + const result = pipe( 177 + invalid(["e1", "e2"]), 178 + matchValidation( 179 + (v: number) => `value: ${v}`, 180 + e => `errors: ${e.join(", ")}` 181 + ) 182 + ) 183 + expect(result).toBe("errors: e1, e2") 184 + }) 185 + }) 186 + 187 + describe("getErrors", () => { 188 + it("returns empty array for Valid", () => { 189 + expect(getErrors(valid(42))).toEqual([]) 190 + }) 191 + 192 + it("returns errors array for Invalid", () => { 193 + expect(getErrors(invalid(["e1", "e2"]))).toEqual(["e1", "e2"]) 194 + }) 195 + }) 196 + 197 + describe("getValue", () => { 198 + it("returns value for Valid", () => { 199 + expect(getValue(valid(42))).toBe(42) 200 + }) 201 + 202 + it("returns undefined for Invalid", () => { 203 + expect(getValue(invalid(["e"]))).toBeUndefined() 204 + }) 205 + }) 206 + })