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 16 effect/prelude/validation combinators with tests and docs

+1271 -20
+68
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + ## [0.1.0-alpha.7] - 2026-02-07 6 + 7 + ### Added 8 + 9 + - **flatMap error/requirement widening** — `flatMap` now automatically unions error and requirement types across composed effects, removing the need for manual type annotations 10 + - **matchOn / matchOnOr** — Pattern matching on custom discriminant fields (not just `_tag`), useful for third-party types or domain events 11 + - **zip / zipWith / zip3** — Typed parallel effect composition returning tuples with automatic error union 12 + - **ensure** — Predicate-based failure combinator for guarding success values in pipelines 13 + - **tapErr** — Error observation side effect (symmetric with `tapEff`), error still propagates 14 + - **traverseResult / sequenceResult** — Apply a fallible function to each element and collect results, short-circuiting on first error 15 + - **traverseOption / sequenceOption** — Apply an Option-returning function to each element, short-circuiting on first None 16 + - **orElseResult / orElseOption** — Recover from errors or None via a fallback function 17 + - **fromNullableResult** — Convert nullable values to Result with a custom error 18 + - **validate2 / validate3 / validate4** — Ergonomic combinators for combining 2-4 validations with error accumulation 19 + - **bracket** — Guaranteed resource cleanup pattern (acquire/use/release) where release always runs 20 + 21 + ### Changed 22 + 23 + - `flatMap` signature changed from `<A, B, E, R>` to split generics `<A, B, E2, R2>` + `<E, R>` for automatic widening 24 + 25 + ## [0.1.0-alpha.6] - 2026-02-07 26 + 27 + ### Added 28 + 29 + - Beaver's Big System story trilogy teaching Branded Types, Typestate, ADTs, and Tracked Arrays 30 + - Forest Election story trilogy teaching Validation, Result, and Effects 31 + - Concept documentation for typestate, effects, fibers, DI, and testing 32 + - Pattern matching examples and O(n) array documentation 33 + 34 + ## [0.1.0-alpha.5] - 2026-02-06 35 + 36 + ### Added 37 + 38 + - Tutorial guide chapters 1-10 39 + - Concept deep-dive articles 40 + - Side-by-side examples (http-client, workflow-engine, task-queue) 41 + 42 + ## [0.1.0-alpha.4] - 2026-02-05 43 + 44 + ### Added 45 + 46 + - Multi-file architecture (prelude, effect, data modules) 47 + - Validation type with error accumulation 48 + - Entity helpers and type guards 49 + 50 + ## [0.1.0-alpha.3] - 2026-02-04 51 + 52 + ### Added 53 + 54 + - Effect combinators (race, all, timeout, retry, repeat) 55 + - Fiber-based concurrency with cancellation 56 + 57 + ## [0.1.0-alpha.2] - 2026-02-03 58 + 59 + ### Added 60 + 61 + - Effect system with typed errors and dependency injection 62 + - Fork/join concurrency model 63 + 64 + ## [0.1.0-alpha.1] - 2026-02-02 65 + 66 + ### Added 67 + 68 + - Initial release with Result, Option, branded types, refinements, pattern matching, tracked arrays, units, and typestate
+12 -2
docs/guides/README.md
··· 69 69 | Story | Concept | Description | 70 70 |--------------------------------------------------------------------|--------------------------|------------------------------------------------| 71 71 | [The Forest Election](./stories/forest-election/) | Validation, Result, Effects | Building a fair election system for the forest | 72 + | [Beaver's Big System](./stories/beavers-big-system/) | Branded Types, Typestate, ADTs, Tracked Arrays | Building maintainable maintenance software | 72 73 73 74 --- 74 75 ··· 90 91 ```typescript 91 92 // Core types and functions 92 93 import { 93 - type Result, ok, err, matchResult, 94 + type Result, ok, err, matchResult, chainResult, mapResult, 95 + traverseResult, sequenceResult, orElseResult, fromNullableResult, 94 96 type Option, some, none, matchOption, 97 + traverseOption, sequenceOption, orElseOption, 95 98 type Eff, succeed, fail, flatMap, pipe, 96 99 type Branded, brand, 97 100 } from "purus-ts" ··· 99 102 // Effect runners 100 103 import { runPromise, runPromiseExit } from "purus-ts" 101 104 102 - // Combinators 105 + // Effect combinators 106 + import { zip, zipWith, zip3, ensure, tapErr, bracket } from "purus-ts" 103 107 import { timeout, retry, race, all } from "purus-ts" 108 + 109 + // Validation 110 + import { valid, invalid, validate2, validate3, validate4 } from "purus-ts" 111 + 112 + // Pattern matching (custom discriminant) 113 + import { match, matchOn, matchOnOr } from "purus-ts" 104 114 ```
+55
docs/guides/concepts/01-errors-as-values.md
··· 380 380 381 381 --- 382 382 383 + ## Working with Collections 384 + 385 + ### traverseResult — Apply a Fallible Function to Each Element 386 + 387 + ```typescript 388 + import { traverseResult, ok, err, pipe } from "purus-ts" 389 + 390 + const parseId = (s: string): Result<number, string> => { 391 + const n = Number(s) 392 + return Number.isNaN(n) ? err(`Invalid: ${s}`) : ok(n) 393 + } 394 + 395 + pipe(["1", "2", "3"], traverseResult(parseId)) // Ok([1, 2, 3]) 396 + pipe(["1", "x", "3"], traverseResult(parseId)) // Err("Invalid: x") 397 + ``` 398 + 399 + Short-circuits on the first error — no wasted work. 400 + 401 + ### sequenceResult — Collect an Array of Results 402 + 403 + ```typescript 404 + import { sequenceResult, ok, err } from "purus-ts" 405 + 406 + sequenceResult([ok(1), ok(2), ok(3)]) // Ok([1, 2, 3]) 407 + sequenceResult([ok(1), err("b")]) // Err("b") 408 + ``` 409 + 410 + ### orElseResult — Recover from Errors 411 + 412 + ```typescript 413 + import { orElseResult, err, ok, pipe } from "purus-ts" 414 + 415 + pipe( 416 + err("cache miss"), 417 + orElseResult(() => ok(42)) 418 + ) 419 + // Ok(42) — recovered via fallback 420 + ``` 421 + 422 + ### fromNullableResult — Convert Nullable to Result 423 + 424 + ```typescript 425 + import { fromNullableResult, pipe } from "purus-ts" 426 + 427 + const findUser = (id: string): User | null => ... 428 + 429 + pipe( 430 + findUser("123"), 431 + fromNullableResult(() => ({ _tag: "NotFound" as const, id: "123" })) 432 + ) 433 + // Ok(user) or Err({ _tag: "NotFound", id: "123" }) 434 + ``` 435 + 436 + --- 437 + 383 438 ## Key Takeaways 384 439 385 440 1. **Exceptions break types** - `catch (e: unknown)` loses all error information
+37
docs/guides/concepts/03-validation-and-error-accumulation.md
··· 515 515 516 516 --- 517 517 518 + ## Ergonomic Combinators: validate2, validate3, validate4 519 + 520 + The curried constructor pattern works but is verbose. For common cases, purus provides direct combinators: 521 + 522 + ```typescript 523 + import { validate2, validate3, valid, invalidOne } from "purus-ts" 524 + 525 + // Instead of: 526 + // pipe(valid(makeUser), apValidation(vName), apValidation(vAge)) 527 + // Write: 528 + const result = validate2( 529 + validateName("Alice"), 530 + validateAge(30), 531 + (name, age) => ({ name, age }) 532 + ) 533 + 534 + // Three fields 535 + const result3 = validate3( 536 + validateName("Alice"), 537 + validateAge(30), 538 + validateEmail("alice@test.com"), 539 + (name, age, email) => ({ name, age, email }) 540 + ) 541 + ``` 542 + 543 + These work exactly like the `apValidation` chain but without needing a curried constructor. Errors still accumulate from all fields. 544 + 545 + | Combinator | Fields | Signature | 546 + |--------------|--------|-----------------------------------------------------| 547 + | `validate2` | 2 | `(va, vb, f) => Validation<C, E>` | 548 + | `validate3` | 3 | `(va, vb, vc, f) => Validation<D, E>` | 549 + | `validate4` | 4 | `(va, vb, vc, vd, f) => Validation<F, E>` | 550 + 551 + For 5+ fields, use the `apValidation` chain with a curried constructor. 552 + 553 + --- 554 + 518 555 ## When to Use Validation vs Result 519 556 520 557 | Use Case | Type | Why |
+108 -10
docs/guides/concepts/06-effect-composition.md
··· 224 224 225 225 ### Widening Error Types 226 226 227 + `flatMap` automatically widens both error and requirement types to the union. You never need to annotate or cast: 228 + 227 229 ```typescript 228 230 type DbError = { _tag: "DbError"; message: string } 229 231 type ApiError = { _tag: "ApiError"; status: number } ··· 234 236 // This effect can fail with ApiError 235 237 const sendEmail: Eff<void, ApiError, unknown> = ... 236 238 237 - // Composed effect can fail with either 239 + // Composed effect can fail with either — inferred automatically 238 240 const program: Eff<void, DbError | ApiError, unknown> = pipe( 239 241 getUser, 240 242 flatMap(user => sendEmail(user.email)) 241 243 ) 242 244 ``` 243 245 244 - TypeScript unions the error types automatically. 246 + This also works for requirements: 247 + 248 + ```typescript 249 + type Db = { db: string } 250 + type Logger = { logger: string } 251 + 252 + const dbEffect: Eff<number, never, Db> = access((_: Db) => 1) 253 + 254 + const result = pipe( 255 + dbEffect, 256 + flatMap(n => access((env: Logger) => env.logger + n)) 257 + ) 258 + // Type: Eff<string, never, Db | Logger> 259 + ``` 260 + 261 + TypeScript unions both error and requirement types automatically. 245 262 246 263 ### Recovering from Errors 247 264 ··· 294 311 ) 295 312 ``` 296 313 297 - ### Parallel Operations 314 + ### Parallel with zip 315 + 316 + Use `zip` to combine two effects into a typed tuple, running them concurrently: 317 + 318 + ```typescript 319 + import { zip, zipWith, zip3, runPromise } from "purus-ts" 320 + 321 + // Two effects → typed tuple 322 + const pair = zip(fetchUser(id), fetchPosts(id)) 323 + // Type: Eff<readonly [User, Post[]], UserError | PostError, unknown> 324 + 325 + // Combine with a function 326 + const total = zipWith(getPrice(id), getTax(id), (price, tax) => price + tax) 327 + 328 + // Three effects 329 + const [users, posts, comments] = await runPromise( 330 + zip3(fetchUsers(), fetchPosts(), fetchComments()) 331 + ) 332 + ``` 333 + 334 + ### Parallel with all 298 335 299 - Use `all` combinator: 336 + Use `all` combinator for homogeneous collections: 300 337 301 338 ```typescript 302 339 import { all } from "purus-ts" ··· 306 343 fetchOrders(id), 307 344 fetchPreferences(id) 308 345 ]) 309 - // Type: Eff<[User, Order[], Preferences], Error, unknown> 346 + // Type: Eff<readonly [User, Order[], Preferences], Error, unknown> 310 347 ``` 311 348 312 349 ### Racing ··· 316 353 ```typescript 317 354 import { race } from "purus-ts" 318 355 319 - const fastest = race([ 356 + const fastest = race( 320 357 fetchFromServer1(key), 321 358 fetchFromServer2(key) 322 - ]) 359 + ) 323 360 // Returns whichever completes first 324 361 ``` 325 362 326 363 --- 327 364 365 + ## Guarding and Observing 366 + 367 + ### ensure — Predicate-Based Failure 368 + 369 + Check a condition on the success value and fail with a custom error if it doesn't hold: 370 + 371 + ```typescript 372 + import { ensure, pipe, succeed } from "purus-ts" 373 + 374 + const program = pipe( 375 + loadMixtape(id), 376 + ensure( 377 + (m) => m.userId === currentUserId, 378 + () => ({ _tag: "Forbidden" as const }) 379 + ) 380 + ) 381 + // Type: Eff<Mixtape, LoadError | { _tag: "Forbidden" }, unknown> 382 + ``` 383 + 384 + The value passes through unchanged when the predicate is true. If the effect is already failed, the predicate is never called. 385 + 386 + ### tapErr — Observe Errors 387 + 388 + Run a side effect on the error channel without consuming the error: 389 + 390 + ```typescript 391 + import { tapErr, pipe } from "purus-ts" 392 + 393 + const program = pipe( 394 + loadUser(id), 395 + tapErr((e) => logger.error("Load failed", { error: e })), 396 + tapEff((u) => logger.debug("Loaded", { id: u.id })) 397 + ) 398 + // Error still propagates — tapErr is purely observational 399 + ``` 400 + 401 + --- 402 + 403 + ## Resource Safety with bracket 404 + 405 + `bracket` guarantees resource cleanup, even when the use step fails: 406 + 407 + ```typescript 408 + import { bracket, sync } from "purus-ts" 409 + 410 + const withTransaction = <A, E>( 411 + operation: (tx: Transaction) => Eff<A, E, unknown> 412 + ): Eff<A, E, unknown> => 413 + bracket( 414 + sync(() => db.beginTransaction()), // acquire 415 + (tx) => sync(() => tx.close()), // release (always runs) 416 + operation // use 417 + ) 418 + ``` 419 + 420 + If `acquire` fails, `use` and `release` are skipped. If `use` fails, `release` still runs and the original error propagates. 421 + 422 + --- 423 + 328 424 ## Key Takeaways 329 425 330 426 1. **Effects are data** - They describe computation, they don't run it 331 - 2. **flatMap sequences effects** - The fundamental composition operator 427 + 2. **flatMap sequences effects** - The fundamental composition operator, with automatic error/requirement widening 332 428 3. **pipe() enables readability** - Top-to-bottom instead of inside-out 333 429 4. **Errors short-circuit** - Failed effects skip subsequent flatMaps 334 - 5. **Build custom combinators** - Compose basics into domain operations 335 - 6. **Both channels compose** - Success types narrow, error types widen 430 + 5. **zip combines effects** - Typed parallel composition with `zip`, `zipWith`, `zip3` 431 + 6. **ensure guards values** - Predicate-based failure without manual flatMap+fail 432 + 7. **bracket guarantees cleanup** - Acquire/use/release with guaranteed finalizer 433 + 8. **Both channels compose** - Success types narrow, error types widen 336 434 337 435 Understanding effect composition is the key to productive purus usage. Once you see effects as composable data, complex programs become pipelines of simple transformations. 338 436
+1 -1
package.json
··· 1 1 { 2 2 "name": "purus-ts", 3 - "version": "0.1.0-alpha.6", 3 + "version": "0.1.0-alpha.7", 4 4 "description": "Pure TypeScript effect system with fiber-based concurrency, brands, refinements, and pattern matching", 5 5 "type": "module", 6 6 "main": "./dist/index.js",
+62
src/data/validation.ts
··· 24 24 * @module data/validation 25 25 */ 26 26 27 + import { pipe } from "../prelude/compose" 27 28 import { err, ok, type Result } from "../prelude/result" 28 29 29 30 // ----------------------------------------------------------------------------- ··· 264 265 */ 265 266 export const getValue = <A, E>(v: Validation<A, E>): A | undefined => 266 267 v._tag === "Valid" ? v.value : undefined 268 + 269 + // ----------------------------------------------------------------------------- 270 + // Ergonomic Combinators 271 + // ----------------------------------------------------------------------------- 272 + 273 + /** 274 + * Combine 2 validations, accumulating errors. 275 + * 276 + * @example 277 + * ```typescript 278 + * validate2( 279 + * validateName(form), 280 + * validateAge(form), 281 + * (name, age) => ({ name, age }) 282 + * ) 283 + * ``` 284 + */ 285 + export const validate2 = <A, B, C, E>( 286 + va: Validation<A, E>, 287 + vb: Validation<B, E>, 288 + f: (a: A, b: B) => C, 289 + ): Validation<C, E> => 290 + pipe( 291 + valid((a: A) => (b: B) => f(a, b)), 292 + apValidation(va), 293 + apValidation(vb), 294 + ) 295 + 296 + /** 297 + * Combine 3 validations, accumulating errors. 298 + */ 299 + export const validate3 = <A, B, C, D, E>( 300 + va: Validation<A, E>, 301 + vb: Validation<B, E>, 302 + vc: Validation<C, E>, 303 + f: (a: A, b: B, c: C) => D, 304 + ): Validation<D, E> => 305 + pipe( 306 + valid((a: A) => (b: B) => (c: C) => f(a, b, c)), 307 + apValidation(va), 308 + apValidation(vb), 309 + apValidation(vc), 310 + ) 311 + 312 + /** 313 + * Combine 4 validations, accumulating errors. 314 + */ 315 + export const validate4 = <A, B, C, D, F, E>( 316 + va: Validation<A, E>, 317 + vb: Validation<B, E>, 318 + vc: Validation<C, E>, 319 + vd: Validation<D, E>, 320 + f: (a: A, b: B, c: C, d: D) => F, 321 + ): Validation<F, E> => 322 + pipe( 323 + valid((a: A) => (b: B) => (c: C) => (d: D) => f(a, b, c, d)), 324 + apValidation(va), 325 + apValidation(vb), 326 + apValidation(vc), 327 + apValidation(vd), 328 + )
+182
src/effect/combinators.ts
··· 4 4 async, 5 5 type Eff, 6 6 type Fiber, 7 + fail, 7 8 flatMap, 8 9 foldEff, 9 10 fork, ··· 23 24 f(a) 24 25 return succeed(a) as Eff<A, E, R> 25 26 }), 27 + ) 28 + 29 + // === Ensure - predicate-based failure === 30 + 31 + /** 32 + * Fail with a custom error if the predicate returns false. 33 + * The value passes through unchanged when the predicate is true. 34 + * 35 + * @example 36 + * ```typescript 37 + * pipe( 38 + * loadMixtape(id), 39 + * ensure( 40 + * (m) => m.userId === userId, 41 + * () => ({ _tag: "Forbidden" as const }) 42 + * ), 43 + * ) 44 + * ``` 45 + */ 46 + export const ensure = <A, E2>( 47 + predicate: (a: A) => boolean, 48 + onFalse: (a: A) => E2, 49 + ) => 50 + <E, R>(eff: Eff<A, E, R>): Eff<A, E | E2, R> => 51 + pipe( 52 + eff, 53 + flatMap((a) => 54 + predicate(a) 55 + ? (succeed(a) as Eff<A, E | E2, R>) 56 + : (fail(onFalse(a)) as Eff<A, E | E2, R>), 57 + ), 58 + ) as Eff<A, E | E2, R> 59 + 60 + // === TapErr - side effect on error === 61 + 62 + /** 63 + * Observe an error without consuming it. The error still propagates. 64 + * Symmetric with tapEff which observes success values. 65 + * 66 + * @example 67 + * ```typescript 68 + * pipe( 69 + * loadUser(id), 70 + * tapErr((e) => logger.error("Load failed", { error: e })), 71 + * tapEff((u) => logger.debug("Loaded", { id: u.id })), 72 + * ) 73 + * ``` 74 + */ 75 + export const tapErr = <E>(f: (e: E) => void) => 76 + <A, R>(eff: Eff<A, E, R>): Eff<A, E, R> => 77 + pipe( 78 + eff, 79 + foldEff( 80 + (e: E) => { 81 + f(e) 82 + return fail(e) as Eff<A, E, R> 83 + }, 84 + (a: A) => succeed(a) as Eff<A, E, R>, 85 + ), 26 86 ) 27 87 28 88 // === Join === ··· 168 228 ), 169 229 ) 170 230 231 + // === Zip - combine effects into tuples === 232 + 233 + /** 234 + * Combine two effects into a tuple, running them concurrently. 235 + * Error types are automatically widened to the union. 236 + * 237 + * @example 238 + * ```typescript 239 + * const result = await runPromise(zip(fetchUser(id), fetchPosts(id))) 240 + * // readonly [User, Post[]] 241 + * ``` 242 + */ 243 + export const zip = <A, B, E1, E2, R1, R2>( 244 + eff1: Eff<A, E1, R1>, 245 + eff2: Eff<B, E2, R2>, 246 + ): Eff<readonly [A, B], E1 | E2, R1 | R2> => 247 + pipe( 248 + fork(eff1 as Eff<A, E1 | E2, R1 | R2>) as unknown as Eff< 249 + Fiber<A, E1 | E2>, 250 + E1 | E2, 251 + R1 | R2 252 + >, 253 + flatMap((fiber1) => 254 + pipe( 255 + eff2 as Eff<B, E1 | E2, R1 | R2>, 256 + flatMap((b) => 257 + pipe( 258 + join(fiber1), 259 + mapEff((a): readonly [A, B] => [a, b] as const), 260 + ), 261 + ), 262 + ), 263 + ), 264 + ) as Eff<readonly [A, B], E1 | E2, R1 | R2> 265 + 266 + /** 267 + * Combine two effects with a mapping function, running them concurrently. 268 + * 269 + * @example 270 + * ```typescript 271 + * const total = zipWith(getPrice(id), getTax(id), (price, tax) => price + tax) 272 + * ``` 273 + */ 274 + export const zipWith = <A, B, C, E1, E2, R1, R2>( 275 + eff1: Eff<A, E1, R1>, 276 + eff2: Eff<B, E2, R2>, 277 + f: (a: A, b: B) => C, 278 + ): Eff<C, E1 | E2, R1 | R2> => 279 + pipe( 280 + zip(eff1, eff2), 281 + mapEff(([a, b]) => f(a, b)), 282 + ) 283 + 284 + /** 285 + * Combine three effects into a tuple, running them concurrently. 286 + * 287 + * @example 288 + * ```typescript 289 + * const [users, posts, comments] = await runPromise( 290 + * zip3(fetchUsers(), fetchPosts(), fetchComments()) 291 + * ) 292 + * ``` 293 + */ 294 + export const zip3 = <A, B, C, E1, E2, E3, R1, R2, R3>( 295 + eff1: Eff<A, E1, R1>, 296 + eff2: Eff<B, E2, R2>, 297 + eff3: Eff<C, E3, R3>, 298 + ): Eff<readonly [A, B, C], E1 | E2 | E3, R1 | R2 | R3> => 299 + pipe( 300 + zip(eff1, eff2) as Eff<readonly [A, B], E1 | E2 | E3, R1 | R2 | R3>, 301 + flatMap(([a, b]) => 302 + pipe( 303 + eff3 as Eff<C, E1 | E2 | E3, R1 | R2 | R3>, 304 + mapEff((c): readonly [A, B, C] => [a, b, c] as const), 305 + ), 306 + ), 307 + ) as Eff<readonly [A, B, C], E1 | E2 | E3, R1 | R2 | R3> 308 + 171 309 // === Traverse / Sequence === 172 310 173 311 /** ··· 305 443 flatMap((a) => go(times - 1, a)), 306 444 ) 307 445 } 446 + 447 + // === Bracket - guaranteed resource cleanup === 448 + 449 + /** 450 + * Acquire a resource, use it, and guarantee cleanup. 451 + * Release runs whether use succeeds or fails (like try/finally). 452 + * 453 + * @example 454 + * ```typescript 455 + * const withTransaction = <A, E>( 456 + * operation: (tx: Transaction) => Eff<A, E, unknown> 457 + * ): Eff<A, E, unknown> => 458 + * bracket( 459 + * sync(() => db.transaction()), 460 + * (tx) => sync(() => tx.close()), 461 + * operation, 462 + * ) 463 + * ``` 464 + */ 465 + export const bracket = <A, B, E, R>( 466 + acquire: Eff<A, E, R>, 467 + release: (a: A) => Eff<void, never, R>, 468 + use: (a: A) => Eff<B, E, R>, 469 + ): Eff<B, E, R> => 470 + pipe( 471 + acquire, 472 + flatMap((resource) => 473 + pipe( 474 + use(resource), 475 + foldEff( 476 + (e) => 477 + pipe( 478 + release(resource) as Eff<void, E, R>, 479 + flatMap(() => fail(e) as Eff<B, E, R>), 480 + ), 481 + (b) => 482 + pipe( 483 + release(resource) as Eff<void, E, R>, 484 + mapEff(() => b), 485 + ), 486 + ), 487 + ), 488 + ), 489 + )
+7 -7
src/effect/eff.ts
··· 199 199 * ``` 200 200 */ 201 201 export const flatMap = 202 - <A, B, E, R>(f: (a: A) => Eff<B, E, R>) => 203 - (effect: Eff<A, E, R>): Eff<B, E, R> => ({ 202 + <A, B, E2, R2>(f: (a: A) => Eff<B, E2, R2>) => 203 + <E, R>(effect: Eff<A, E, R>): Eff<B, E | E2, R | R2> => ({ 204 204 _tag: "FlatMap", 205 - effect: effect as Eff<unknown, E, R>, 206 - f: f as (a: unknown) => Eff<B, E, R>, 205 + effect: effect as Eff<unknown, E | E2, R | R2>, 206 + f: f as (a: unknown) => Eff<B, E | E2, R | R2>, 207 207 }) 208 208 209 209 /** ··· 220 220 * ``` 221 221 */ 222 222 export const mapEff = 223 - <A, B, E, R>(f: (a: A) => B) => 224 - (effect: Eff<A, E, R>): Eff<B, E, R> => 225 - flatMap((a: A) => succeed(f(a)) as Eff<B, E, R>)(effect) 223 + <A, B>(f: (a: A) => B) => 224 + <E, R>(effect: Eff<A, E, R>): Eff<B, E, R> => 225 + flatMap((a: A) => succeed(f(a)))(effect) as Eff<B, E, R> 226 226 227 227 /** 228 228 * Handle both success and failure cases of an effect.
+55
src/prelude/match.ts
··· 80 80 : defaultValue 81 81 82 82 /** 83 + * Pattern matching with a custom discriminant field. 84 + * 85 + * Like match() but works with any string field as the discriminant, 86 + * not just _tag. Useful for unions discriminated by `type`, `kind`, etc. 87 + * 88 + * @example 89 + * ```typescript 90 + * type Event = 91 + * | { type: "click"; x: number; y: number } 92 + * | { type: "keypress"; key: string } 93 + * 94 + * const describe = (e: Event): string => 95 + * matchOn("type")(e)({ 96 + * click: ({ x, y }) => `Click at ${x},${y}`, 97 + * keypress: ({ key }) => `Key: ${key}`, 98 + * }) 99 + * ``` 100 + */ 101 + export const matchOn = 102 + <D extends string>(discriminant: D) => 103 + <T extends Record<D, string>>(value: T) => 104 + <R>(cases: { [K in T[D] & string]: (v: Extract<T, Record<D, K>>) => R }): R => 105 + (cases as unknown as Record<string, (v: T) => R>)[value[discriminant]]!(value) 106 + 107 + /** 108 + * Pattern matching with a custom discriminant and a default case. 109 + * 110 + * Like matchOn() but unhandled cases return the default value. 111 + * 112 + * @example 113 + * ```typescript 114 + * const isSpecial = (e: Event): boolean => 115 + * matchOnOr("type")(false)(e)({ 116 + * click: () => true, 117 + * }) 118 + * ``` 119 + */ 120 + export const matchOnOr = 121 + <D extends string>(discriminant: D) => 122 + <R>(defaultValue: R) => 123 + <T extends Record<D, string>>(value: T) => 124 + ( 125 + cases: Partial<{ 126 + [K in T[D] & string]: (v: Extract<T, Record<D, K>>) => R 127 + }>, 128 + ): R => 129 + (cases as unknown as Record<string, ((v: T) => R) | undefined>)[ 130 + value[discriminant] 131 + ] !== undefined 132 + ? (cases as unknown as Record<string, (v: T) => R>)[ 133 + value[discriminant] 134 + ]!(value) 135 + : defaultValue 136 + 137 + /** 83 138 * Guard-based pattern matching with predicates. 84 139 * 85 140 * Unlike match() which dispatches on _tag, when() uses predicate functions.
+54
src/prelude/option.ts
··· 175 175 */ 176 176 export const toNullable = <T>(option: Option<T>): T | null => 177 177 option._tag === "Some" ? option.value : null 178 + 179 + /** 180 + * Apply an Option-returning function to each element, collecting values. 181 + * Short-circuits on the first None. 182 + * 183 + * @example 184 + * ```typescript 185 + * const lookupAll = traverseOption(findUser)(["id1", "id2"]) 186 + * // Some([user1, user2]) or None 187 + * ``` 188 + */ 189 + export const traverseOption = 190 + <A, B>(f: (a: A) => Option<B>) => 191 + (as: readonly A[]): Option<readonly B[]> => { 192 + const results: B[] = [] 193 + for (const a of as) { 194 + const o = f(a) 195 + if (o._tag === "None") return none 196 + results.push(o.value) 197 + } 198 + return some(results) 199 + } 200 + 201 + /** 202 + * Collapse an array of Options into an Option of an array. 203 + * Returns None if any element is None. 204 + * 205 + * @example 206 + * ```typescript 207 + * sequenceOption([some(1), some(2)]) // Some([1, 2]) 208 + * sequenceOption([some(1), none]) // None 209 + * ``` 210 + */ 211 + export const sequenceOption = <A>( 212 + options: readonly Option<A>[], 213 + ): Option<readonly A[]> => 214 + traverseOption<Option<A>, A>((o) => o)(options) 215 + 216 + /** 217 + * Try an alternative when an Option is None. 218 + * Some passes through unchanged; None invokes the fallback. 219 + * 220 + * @example 221 + * ```typescript 222 + * pipe( 223 + * findInCache(id), 224 + * orElseOption(() => findInDb(id)), 225 + * ) 226 + * ``` 227 + */ 228 + export const orElseOption = 229 + <A>(f: () => Option<A>) => 230 + (option: Option<A>): Option<A> => 231 + option._tag === "Some" ? option : f()
+69
src/prelude/result.ts
··· 239 239 return err(e) 240 240 } 241 241 } 242 + 243 + /** 244 + * Apply a Result-returning function to each element, collecting successes. 245 + * Short-circuits on the first Err. 246 + * 247 + * @example 248 + * ```typescript 249 + * const parseAll = traverseResult(parseInt)(["1", "2", "3"]) 250 + * // Ok([1, 2, 3]) 251 + * ``` 252 + */ 253 + export const traverseResult = 254 + <A, B, E>(f: (a: A) => Result<B, E>) => 255 + (as: readonly A[]): Result<readonly B[], E> => { 256 + const results: B[] = [] 257 + for (const a of as) { 258 + const r = f(a) 259 + if (r._tag === "Err") return r as unknown as Result<readonly B[], E> 260 + results.push(r.value) 261 + } 262 + return ok(results) 263 + } 264 + 265 + /** 266 + * Collapse an array of Results into a Result of an array. 267 + * Short-circuits on the first Err. 268 + * 269 + * @example 270 + * ```typescript 271 + * sequenceResult([ok(1), ok(2), ok(3)]) // Ok([1, 2, 3]) 272 + * sequenceResult([ok(1), err("x")]) // Err("x") 273 + * ``` 274 + */ 275 + export const sequenceResult = <A, E>( 276 + results: readonly Result<A, E>[], 277 + ): Result<readonly A[], E> => 278 + traverseResult<Result<A, E>, A, E>((r) => r)(results) 279 + 280 + /** 281 + * Try an alternative when a Result is Err. 282 + * Ok passes through unchanged; Err invokes the fallback with the error. 283 + * 284 + * @example 285 + * ```typescript 286 + * pipe( 287 + * parseFormat(raw), 288 + * orElseResult((e) => parseFormat(raw.toUpperCase())), 289 + * ) 290 + * ``` 291 + */ 292 + export const orElseResult = 293 + <A, E, F>(f: (e: E) => Result<A, F>) => 294 + (result: Result<A, E>): Result<A, F> => 295 + result._tag === "Ok" ? result : f(result.error) 296 + 297 + /** 298 + * Convert a nullable value to a Result with a custom error. 299 + * Non-null values become Ok; null/undefined becomes Err. 300 + * 301 + * @example 302 + * ```typescript 303 + * const findUser = (id: string): Result<User, NotFoundError> => 304 + * fromNullableResult(() => notFoundError({ resource: "User", id }))(dbRow) 305 + * ``` 306 + */ 307 + export const fromNullableResult = 308 + <E>(onNull: () => E) => 309 + <A>(value: A | null | undefined): Result<NonNullable<A>, E> => 310 + value != null ? ok(value as NonNullable<A>) : err(onNull())
+206
tests/effect-basic.test.ts
··· 2 2 import { 3 3 access, 4 4 attempt, 5 + bracket, 5 6 catchAll, 7 + type Eff, 8 + ensure, 6 9 Exit, 7 10 fail, 8 11 flatMap, ··· 15 18 runPromiseExit, 16 19 succeed, 17 20 sync, 21 + tapErr, 18 22 } from "../src/index" 19 23 20 24 describe("Effect - Basic Operations", () => { ··· 92 96 ) 93 97 expect(result).toBe("recovered") 94 98 }) 99 + 100 + it("flatMap widens error types automatically", async () => { 101 + type DbError = { _tag: "DbError" } 102 + type NotFound = { _tag: "NotFound" } 103 + 104 + const dbEffect: Eff<number, DbError, unknown> = succeed(42) 105 + 106 + const result = pipe( 107 + dbEffect, 108 + flatMap((n): Eff<string, NotFound, unknown> => 109 + n > 0 ? succeed(String(n)) : fail({ _tag: "NotFound" }), 110 + ), 111 + ) 112 + 113 + expect(await runPromise(result)).toBe("42") 114 + }) 115 + 116 + it("flatMap widens requirement types automatically", async () => { 117 + type Db = { db: string } 118 + type Logger = { logger: string } 119 + 120 + const dbEffect: Eff<number, never, Db> = access((_: Db) => 1) 121 + 122 + const result = pipe( 123 + dbEffect, 124 + flatMap((n) => access((env: Logger) => env.logger + n)), 125 + ) 126 + 127 + const value = await runPromise( 128 + pipe( 129 + result, 130 + provide({ db: "pg", logger: "console" } as Db & Logger), 131 + ), 132 + ) 133 + expect(value).toBe("console1") 134 + }) 135 + }) 136 + 137 + describe("ensure", () => { 138 + it("passes value through when predicate is true", async () => { 139 + const result = await runPromise( 140 + pipe( 141 + succeed(42), 142 + ensure( 143 + (n) => n > 0, 144 + (n) => ({ _tag: "NotPositive" as const, value: n }), 145 + ), 146 + ), 147 + ) 148 + expect(result).toBe(42) 149 + }) 150 + 151 + it("fails with mapped error when predicate is false", async () => { 152 + const exit = await runPromiseExit( 153 + pipe( 154 + succeed(-1), 155 + ensure( 156 + (n) => n > 0, 157 + (n) => ({ _tag: "NotPositive" as const, value: n }), 158 + ), 159 + ), 160 + ) 161 + expect(Exit.isFailure(exit)).toBe(true) 162 + if (Exit.isFailure(exit)) { 163 + expect(exit.error).toEqual({ _tag: "NotPositive", value: -1 }) 164 + } 165 + }) 166 + 167 + it("does not run predicate on failed effect", async () => { 168 + let called = false 169 + const exit = await runPromiseExit( 170 + pipe( 171 + fail("original"), 172 + ensure( 173 + () => { 174 + called = true 175 + return true 176 + }, 177 + () => "never", 178 + ), 179 + ), 180 + ) 181 + expect(Exit.isFailure(exit)).toBe(true) 182 + if (Exit.isFailure(exit)) expect(exit.error).toBe("original") 183 + expect(called).toBe(false) 184 + }) 185 + }) 186 + 187 + describe("tapErr", () => { 188 + it("runs side effect on error", async () => { 189 + let observed: string | null = null 190 + const exit = await runPromiseExit( 191 + pipe( 192 + fail("boom"), 193 + tapErr((e: string) => { 194 + observed = e 195 + }), 196 + ), 197 + ) 198 + expect(Exit.isFailure(exit)).toBe(true) 199 + expect(observed as unknown as string).toBe("boom") 200 + }) 201 + 202 + it("error still propagates after tap", async () => { 203 + const exit = await runPromiseExit( 204 + pipe( 205 + fail("boom"), 206 + tapErr(() => {}), 207 + ), 208 + ) 209 + expect(Exit.isFailure(exit)).toBe(true) 210 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 211 + }) 212 + 213 + it("does not run side effect on success", async () => { 214 + let called = false 215 + const result = await runPromise( 216 + pipe( 217 + succeed(42), 218 + tapErr(() => { 219 + called = true 220 + }), 221 + ), 222 + ) 223 + expect(result).toBe(42) 224 + expect(called).toBe(false) 225 + }) 95 226 }) 96 227 97 228 describe("dependency injection", () => { ··· 106 237 ), 107 238 ) 108 239 expect(result).toBe("https://api.example.com") 240 + }) 241 + }) 242 + 243 + describe("bracket", () => { 244 + it("runs acquire, use, and release in order", async () => { 245 + const log: string[] = [] 246 + 247 + const result = await runPromise( 248 + bracket( 249 + sync(() => { 250 + log.push("acquire") 251 + return "resource" 252 + }), 253 + (r) => 254 + sync(() => { 255 + log.push(`release:${r}`) 256 + }), 257 + (r) => 258 + sync(() => { 259 + log.push(`use:${r}`) 260 + return 42 261 + }), 262 + ), 263 + ) 264 + 265 + expect(result).toBe(42) 266 + expect(log).toEqual(["acquire", "use:resource", "release:resource"]) 267 + }) 268 + 269 + it("runs release even when use fails", async () => { 270 + const log: string[] = [] 271 + 272 + const exit = await runPromiseExit( 273 + bracket( 274 + sync(() => { 275 + log.push("acquire") 276 + return "resource" 277 + }), 278 + (r) => 279 + sync(() => { 280 + log.push(`release:${r}`) 281 + }), 282 + (_r) => { 283 + log.push("use") 284 + return fail("boom") 285 + }, 286 + ), 287 + ) 288 + 289 + expect(Exit.isFailure(exit)).toBe(true) 290 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 291 + expect(log).toEqual(["acquire", "use", "release:resource"]) 292 + }) 293 + 294 + it("skips use and release when acquire fails", async () => { 295 + const log: string[] = [] 296 + 297 + const exit = await runPromiseExit( 298 + bracket( 299 + fail("acquire-fail") as Eff<string, string, unknown>, 300 + (r) => 301 + sync(() => { 302 + log.push(`release:${r}`) 303 + }), 304 + (r) => 305 + sync(() => { 306 + log.push(`use:${r}`) 307 + return 42 308 + }), 309 + ), 310 + ) 311 + 312 + expect(Exit.isFailure(exit)).toBe(true) 313 + if (Exit.isFailure(exit)) expect(exit.error).toBe("acquire-fail") 314 + expect(log).toEqual([]) 109 315 }) 110 316 }) 111 317 })
+58
tests/effect-concurrency.test.ts
··· 20 20 timeout, 21 21 traverse, 22 22 traversePar, 23 + zip, 24 + zip3, 25 + zipWith, 23 26 } from "../src/index" 24 27 25 28 describe("Effect - Concurrency", () => { ··· 245 248 246 249 const exit = await runPromiseExit(pipe([1, 2, 3], traversePar(f))) 247 250 expect(Exit.isFailure(exit)).toBe(true) 251 + }) 252 + }) 253 + 254 + describe("zip", () => { 255 + it("combines two effects into a tuple", async () => { 256 + const result = await runPromise(zip(succeed(1), succeed("hello"))) 257 + expect(result).toEqual([1, "hello"]) 258 + }) 259 + 260 + it("propagates error from first effect", async () => { 261 + const exit = await runPromiseExit(zip(fail("boom"), succeed("hello"))) 262 + expect(Exit.isFailure(exit)).toBe(true) 263 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 264 + }) 265 + 266 + it("propagates error from second effect", async () => { 267 + const exit = await runPromiseExit(zip(succeed(1), fail("boom"))) 268 + expect(Exit.isFailure(exit)).toBe(true) 269 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 270 + }) 271 + 272 + it("runs effects concurrently", async () => { 273 + const start = Date.now() 274 + const eff1 = pipe(sleep(50), mapEff(() => "a")) 275 + const eff2 = pipe(sleep(50), mapEff(() => "b")) 276 + const result = await runPromise(zip(eff1, eff2)) 277 + const elapsed = Date.now() - start 278 + expect(result).toEqual(["a", "b"]) 279 + expect(elapsed).toBeLessThan(100) 280 + }) 281 + }) 282 + 283 + describe("zipWith", () => { 284 + it("combines two effects with a mapping function", async () => { 285 + const result = await runPromise( 286 + zipWith(succeed(10), succeed(20), (a, b) => a + b), 287 + ) 288 + expect(result).toBe(30) 289 + }) 290 + }) 291 + 292 + describe("zip3", () => { 293 + it("combines three effects into a tuple", async () => { 294 + const result = await runPromise( 295 + zip3(succeed(1), succeed("hello"), succeed(true)), 296 + ) 297 + expect(result).toEqual([1, "hello", true]) 298 + }) 299 + 300 + it("propagates error from any effect", async () => { 301 + const exit = await runPromiseExit( 302 + zip3(succeed(1), fail("boom"), succeed(true)), 303 + ) 304 + expect(Exit.isFailure(exit)).toBe(true) 305 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 248 306 }) 249 307 }) 250 308
+73
tests/option.test.ts
··· 5 5 getOrElse, 6 6 mapOption, 7 7 none, 8 + type Option, 9 + orElseOption, 8 10 pipe, 11 + sequenceOption, 9 12 some, 13 + traverseOption, 10 14 } from "../src/index" 11 15 12 16 describe("Option", () => { ··· 59 63 const safeSqrt = (x: number) => (x >= 0 ? some(Math.sqrt(x)) : none) 60 64 const result = pipe(some(16), flatMapOption(safeSqrt)) 61 65 expect(result).toEqual(some(4)) 66 + }) 67 + }) 68 + 69 + describe("traverseOption", () => { 70 + it("returns Some array when all succeed", () => { 71 + const result = traverseOption((n: number) => 72 + n > 0 ? some(n * 2) : none, 73 + )([1, 2, 3]) 74 + expect(result).toEqual(some([2, 4, 6])) 75 + }) 76 + 77 + it("returns None when any fail", () => { 78 + const result = traverseOption((n: number) => 79 + n > 0 ? some(n * 2) : none, 80 + )([1, -2, 3]) 81 + expect(result).toEqual(none) 82 + }) 83 + 84 + it("returns Some([]) for empty array", () => { 85 + const result = traverseOption((n: number) => some(n))([]) 86 + expect(result).toEqual(some([])) 87 + }) 88 + }) 89 + 90 + describe("sequenceOption", () => { 91 + it("returns Some array when all are Some", () => { 92 + const result = sequenceOption([some(1), some(2), some(3)]) 93 + expect(result).toEqual(some([1, 2, 3])) 94 + }) 95 + 96 + it("returns None when any is None", () => { 97 + const result = sequenceOption([some(1), none, some(3)]) 98 + expect(result).toEqual(none) 99 + }) 100 + 101 + it("returns Some([]) for empty array", () => { 102 + const result = sequenceOption([]) 103 + expect(result).toEqual(some([])) 104 + }) 105 + }) 106 + 107 + describe("orElseOption", () => { 108 + it("returns Some unchanged without calling fallback", () => { 109 + let called = false 110 + const result = pipe( 111 + some(42), 112 + orElseOption(() => { 113 + called = true 114 + return some(0) 115 + }), 116 + ) 117 + expect(result).toEqual(some(42)) 118 + expect(called).toBe(false) 119 + }) 120 + 121 + it("calls fallback on None", () => { 122 + const result = pipe( 123 + none as Option<number>, 124 + orElseOption(() => some(99)), 125 + ) 126 + expect(result).toEqual(some(99)) 127 + }) 128 + 129 + it("fallback can also return None", () => { 130 + const result = pipe( 131 + none as Option<number>, 132 + orElseOption(() => none), 133 + ) 134 + expect(result).toEqual(none) 62 135 }) 63 136 }) 64 137
+48
tests/pattern-matching.test.ts
··· 2 2 import { 3 3 err, 4 4 match, 5 + matchOn, 6 + matchOnOr, 5 7 matchOption, 6 8 matchOr, 7 9 matchResult, ··· 49 51 expect(grade(95)).toBe("A") 50 52 expect(grade(85)).toBe("B") 51 53 expect(grade(65)).toBe("F") 54 + }) 55 + }) 56 + 57 + describe("matchOn", () => { 58 + type ContentError = 59 + | { type: "EMPTY"; message: string } 60 + | { type: "TOO_LONG"; maxLength: number; actualLength: number } 61 + | { type: "INVALID"; message: string } 62 + 63 + const describeError = (error: ContentError): string => 64 + matchOn("type")(error)({ 65 + EMPTY: (e) => `Empty: ${e.message}`, 66 + TOO_LONG: (e) => `Too long: ${e.actualLength}/${e.maxLength}`, 67 + INVALID: (e) => `Invalid: ${e.message}`, 68 + }) 69 + 70 + it("matches on custom discriminant field", () => { 71 + expect(describeError({ type: "TOO_LONG", maxLength: 100, actualLength: 200 })) 72 + .toBe("Too long: 200/100") 73 + }) 74 + 75 + it("narrows type in handlers", () => { 76 + expect(describeError({ type: "EMPTY", message: "Cannot be empty" })) 77 + .toBe("Empty: Cannot be empty") 78 + }) 79 + }) 80 + 81 + describe("matchOnOr", () => { 82 + type Status = 83 + | { kind: "active" } 84 + | { kind: "inactive" } 85 + | { kind: "banned"; reason: string } 86 + 87 + const describeStatus = (status: Status): string => 88 + matchOnOr("kind")("unknown")(status)({ 89 + banned: (s) => `Banned: ${s.reason}`, 90 + }) 91 + 92 + it("returns handler result for matched case", () => { 93 + expect(describeStatus({ kind: "banned", reason: "spam" })) 94 + .toBe("Banned: spam") 95 + }) 96 + 97 + it("returns default for unmatched case", () => { 98 + expect(describeStatus({ kind: "active" })) 99 + .toBe("unknown") 52 100 }) 53 101 }) 54 102
+96
tests/result.test.ts
··· 3 3 bimap, 4 4 chainResult, 5 5 err, 6 + fromNullableResult, 6 7 mapResult, 7 8 ok, 9 + orElseResult, 8 10 pipe, 11 + sequenceResult, 12 + traverseResult, 9 13 tryCatch, 10 14 unwrapOr, 11 15 } from "../src/index" ··· 83 87 it("catches exceptions", () => { 84 88 const result = tryCatch(() => JSON.parse("invalid")) 85 89 expect(result._tag).toBe("Err") 90 + }) 91 + }) 92 + 93 + describe("traverseResult", () => { 94 + it("returns Ok array when all succeed", () => { 95 + const result = traverseResult((n: number) => 96 + n > 0 ? ok(n * 2) : err("negative"), 97 + )([1, 2, 3]) 98 + expect(result).toEqual(ok([2, 4, 6])) 99 + }) 100 + 101 + it("returns first Err when any fail", () => { 102 + const result = traverseResult((n: number) => 103 + n > 0 ? ok(n * 2) : err(`${n} is negative`), 104 + )([1, -2, 3, -4]) 105 + expect(result).toEqual(err("-2 is negative")) 106 + }) 107 + 108 + it("returns Ok([]) for empty array", () => { 109 + const result = traverseResult((n: number) => ok(n))([]) 110 + expect(result).toEqual(ok([])) 111 + }) 112 + }) 113 + 114 + describe("sequenceResult", () => { 115 + it("returns Ok array when all are Ok", () => { 116 + const result = sequenceResult([ok(1), ok(2), ok(3)]) 117 + expect(result).toEqual(ok([1, 2, 3])) 118 + }) 119 + 120 + it("returns first Err", () => { 121 + const result = sequenceResult([ok(1), err("boom"), ok(3)]) 122 + expect(result).toEqual(err("boom")) 123 + }) 124 + 125 + it("returns Ok([]) for empty array", () => { 126 + const result = sequenceResult([]) 127 + expect(result).toEqual(ok([])) 128 + }) 129 + }) 130 + 131 + describe("orElseResult", () => { 132 + it("returns Ok unchanged without calling fallback", () => { 133 + let called = false 134 + const result = pipe( 135 + ok(42), 136 + orElseResult(() => { 137 + called = true 138 + return ok(0) 139 + }), 140 + ) 141 + expect(result).toEqual(ok(42)) 142 + expect(called).toBe(false) 143 + }) 144 + 145 + it("calls fallback on Err with the error value", () => { 146 + const result = pipe( 147 + err("not found"), 148 + orElseResult((e) => ok(`recovered from: ${e}`)), 149 + ) 150 + expect(result).toEqual(ok("recovered from: not found")) 151 + }) 152 + 153 + it("fallback can also return Err", () => { 154 + const result = pipe( 155 + err("first"), 156 + orElseResult(() => err("second")), 157 + ) 158 + expect(result).toEqual(err("second")) 159 + }) 160 + }) 161 + 162 + describe("fromNullableResult", () => { 163 + it("returns Ok for non-null value", () => { 164 + const result = fromNullableResult(() => "was null")(42) 165 + expect(result).toEqual(ok(42)) 166 + }) 167 + 168 + it("returns Ok for falsy non-null values", () => { 169 + expect(fromNullableResult(() => "null")(0)).toEqual(ok(0)) 170 + expect(fromNullableResult(() => "null")("")).toEqual(ok("")) 171 + expect(fromNullableResult(() => "null")(false)).toEqual(ok(false)) 172 + }) 173 + 174 + it("returns Err for null", () => { 175 + const result = fromNullableResult(() => "was null")(null) 176 + expect(result).toEqual(err("was null")) 177 + }) 178 + 179 + it("returns Err for undefined", () => { 180 + const result = fromNullableResult(() => "was undefined")(undefined) 181 + expect(result).toEqual(err("was undefined")) 86 182 }) 87 183 }) 88 184
+80
tests/validation.test.ts
··· 16 16 toResult, 17 17 toResultAll, 18 18 valid, 19 + validate2, 20 + validate3, 21 + validate4, 19 22 } from "../src/index" 20 23 21 24 describe("Validation", () => { ··· 202 205 ), 203 206 ) 204 207 expect(result).toBe("errors: e1, e2") 208 + }) 209 + }) 210 + 211 + describe("validate2", () => { 212 + it("combines two valid values", () => { 213 + const result = validate2( 214 + valid("Alice"), 215 + valid(30), 216 + (name, age) => ({ name, age }), 217 + ) 218 + expect(result).toEqual(valid({ name: "Alice", age: 30 })) 219 + }) 220 + 221 + it("returns error when first is invalid", () => { 222 + const result = validate2( 223 + invalidOne("name required"), 224 + valid(30), 225 + (name: string, age: number) => ({ name, age }), 226 + ) 227 + expect(result).toEqual(invalid(["name required"])) 228 + }) 229 + 230 + it("accumulates errors from both", () => { 231 + const result = validate2( 232 + invalidOne("name required"), 233 + invalidOne("age required"), 234 + (name: string, age: number) => ({ name, age }), 235 + ) 236 + expect(result).toEqual(invalid(["name required", "age required"])) 237 + }) 238 + }) 239 + 240 + describe("validate3", () => { 241 + it("combines three valid values", () => { 242 + const result = validate3( 243 + valid("Alice"), 244 + valid(30), 245 + valid("alice@test.com"), 246 + (name, age, email) => ({ name, age, email }), 247 + ) 248 + expect(result).toEqual( 249 + valid({ name: "Alice", age: 30, email: "alice@test.com" }), 250 + ) 251 + }) 252 + 253 + it("accumulates errors from all three", () => { 254 + const result = validate3( 255 + invalidOne("e1"), 256 + invalidOne("e2"), 257 + invalidOne("e3"), 258 + (a: string, b: string, c: string) => a + b + c, 259 + ) 260 + expect(result).toEqual(invalid(["e1", "e2", "e3"])) 261 + }) 262 + }) 263 + 264 + describe("validate4", () => { 265 + it("combines four valid values", () => { 266 + const result = validate4( 267 + valid(1), 268 + valid(2), 269 + valid(3), 270 + valid(4), 271 + (a, b, c, d) => a + b + c + d, 272 + ) 273 + expect(result).toEqual(valid(10)) 274 + }) 275 + 276 + it("accumulates errors from all four", () => { 277 + const result = validate4( 278 + invalidOne("e1"), 279 + invalidOne("e2"), 280 + invalidOne("e3"), 281 + invalidOne("e4"), 282 + (a: number, b: number, c: number, d: number) => a + b + c + d, 283 + ) 284 + expect(result).toEqual(invalid(["e1", "e2", "e3", "e4"])) 205 285 }) 206 286 }) 207 287