An educational pure functional programming library in TypeScript
2
fork

Configure Feed

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

Fix bracket resource leak on interruption, harden validation types, remove duplicate optics, and add 35 tests

+456 -95
+1 -1
examples/stories/forest-election/01-validation.ts
··· 100 100 ): Validation<string, BallotError> => 101 101 matchValidation( 102 102 (v: string) => validateNoDuplicate(v, alreadyVoted), 103 - (errors: readonly BallotError[]) => invalid<BallotError>(errors), 103 + (errors: readonly [BallotError, ...BallotError[]]) => invalid(errors), 104 104 )(validateVoter(voter)) 105 105 106 106 /**
+7 -4
src/data/validation.ts
··· 41 41 */ 42 42 export type Invalid<E> = { 43 43 readonly _tag: "Invalid" 44 - readonly errors: readonly E[] 44 + readonly errors: readonly [E, ...E[]] 45 45 } 46 46 47 47 /** ··· 79 79 * // Type: Invalid<string> 80 80 * ``` 81 81 */ 82 - export const invalid = <E>(errors: readonly E[]): Invalid<E> => ({ 82 + export const invalid = <E>(errors: readonly [E, ...E[]]): Invalid<E> => ({ 83 83 _tag: "Invalid", 84 84 errors, 85 85 }) ··· 203 203 * ``` 204 204 */ 205 205 export const toResult = <A, E>(v: Validation<A, E>): Result<A, E> => 206 - v._tag === "Valid" ? ok(v.value) : err(v.errors[0]!) 206 + v._tag === "Valid" ? ok(v.value) : err(v.errors[0]) 207 207 208 208 /** 209 209 * Convert a Validation to a Result, preserving all errors. ··· 234 234 * ``` 235 235 */ 236 236 export const matchValidation = 237 - <A, E, R>(onValid: (value: A) => R, onInvalid: (errors: readonly E[]) => R) => 237 + <A, E, R>( 238 + onValid: (value: A) => R, 239 + onInvalid: (errors: readonly [E, ...E[]]) => R, 240 + ) => 238 241 (v: Validation<A, E>): R => 239 242 v._tag === "Valid" ? onValid(v.value) : onInvalid(v.errors) 240 243
+16 -1
src/effect/interpret.ts
··· 49 49 const handleExit = matchExit<A, E, Step<unknown, unknown>>( 50 50 (value) => cont.onSuccess(value), 51 51 (error) => cont.onFailure(error), 52 + // Route interruption through the error channel so Fold handlers 53 + // (bracket release) still run. The cast is safe: this error value 54 + // is ephemeral — the fiber-level isInterrupted flag overrides the 55 + // final exit to Interrupted before it reaches user code. 52 56 () => cont.onFailure("interrupted" as E), 53 57 ) 54 58 ··· 57 61 return handleExit(result) 58 62 } 59 63 64 + // When interrupted, set result to Interrupted exit and fire callback 65 + // so the continuation chain (including Fold/bracket) runs normally. 66 + const onInterrupt = () => { 67 + if (result === null) { 68 + result = interrupted("interrupted") as Exit<A, E> 69 + callback?.() 70 + } 71 + } 72 + 60 73 return blocked( 61 74 cleanup, 75 + onInterrupt, 62 76 (cb) => { 63 77 callback = cb 64 78 }, 65 79 // result is set by the async callback before onComplete fires; 66 80 // if it's somehow null (e.g. early cleanup), treat as interrupted. 67 - () => (result !== null ? handleExit(result) : done(interrupted("unknown"))), 81 + () => 82 + result !== null ? handleExit(result) : done(interrupted("unknown")), 68 83 ) 69 84 }, 70 85
+6 -3
src/effect/step.ts
··· 17 17 | { 18 18 readonly _tag: "Blocked" 19 19 readonly cleanup: () => void 20 + readonly onInterrupt: () => void 20 21 readonly onComplete: (cb: () => void) => void 21 22 readonly next: () => Step<A, E> 22 23 } ··· 34 35 35 36 export const blocked = <A, E>( 36 37 cleanup: () => void, 38 + onInterrupt: () => void, 37 39 onComplete: (cb: () => void) => void, 38 40 next: () => Step<A, E>, 39 - ): Step<A, E> => ({ _tag: "Blocked", cleanup, onComplete, next }) 41 + ): Step<A, E> => ({ _tag: "Blocked", cleanup, onInterrupt, onComplete, next }) 40 42 41 43 // Match helper 42 44 export const matchStep = ··· 45 47 onSuspended: (resume: () => Step<A, E>) => R, 46 48 onBlocked: ( 47 49 cleanup: () => void, 50 + onInterrupt: () => void, 48 51 onComplete: (cb: () => void) => void, 49 52 next: () => Step<A, E>, 50 53 ) => R, ··· 53 56 match(step)({ 54 57 Done: ({ exit }) => onDone(exit), 55 58 Suspended: ({ resume }) => onSuspended(resume), 56 - Blocked: ({ cleanup, onComplete, next }) => 57 - onBlocked(cleanup, onComplete, next), 59 + Blocked: ({ cleanup, onInterrupt, onComplete, next }) => 60 + onBlocked(cleanup, onInterrupt, onComplete, next), 58 61 })
+14 -22
src/effect/trampoline.ts
··· 63 63 let isInterrupted = false 64 64 let exitResult: Option<Exit<A, E>> = none 65 65 const awaiters: Array<(exit: Exit<A, E>) => void> = [] 66 - // Track the current async operation's cleanup for cancellation on interrupt. 67 - // When interrupt() is called, we call this cleanup and force-resolve the 68 - // blocked promise so the continuation chain (including foldEff/bracket) 69 - // can still run with the interruption flowing through the error channel. 66 + // Track the current async operation's cleanup and interrupt signal. 67 + // When interrupt() is called, we call cleanup (cancels the async op) 68 + // then onInterrupt (sets result to Interrupted and fires callback), 69 + // so the continuation chain (including foldEff/bracket) runs normally. 70 70 let currentCleanup: (() => void) | null = null 71 - let forceResolveBlocked: (() => void) | null = null 71 + let currentOnInterrupt: (() => void) | null = null 72 72 73 73 const makeFiberFn = <A2, E2>(e: Eff<A2, E2, R>, r: R) => createFiber(e, r) 74 74 ··· 79 79 match(step)({ 80 80 Done: ({ exit }) => { 81 81 currentCleanup = null 82 - forceResolveBlocked = null 82 + currentOnInterrupt = null 83 83 return Promise.resolve(exit) 84 84 }, 85 85 86 - Suspended: ({ resume }) => 87 - Promise.resolve().then(() => run(resume())), 86 + Suspended: ({ resume }) => Promise.resolve().then(() => run(resume())), 88 87 89 - Blocked: ({ cleanup, onComplete, next }) => { 88 + Blocked: ({ cleanup, onInterrupt, onComplete, next }) => { 90 89 currentCleanup = cleanup 90 + currentOnInterrupt = onInterrupt 91 91 return new Promise((resolve) => { 92 - // When interrupt() is called during a Blocked step, the async 93 - // cleanup fires (cancelling the operation), and we force-resolve 94 - // with an Interrupted exit. The next() thunk cannot be used here 95 - // because it relies on the async callback's result being set. 96 - forceResolveBlocked = () => { 97 - forceResolveBlocked = null 98 - currentCleanup = null 99 - resolve(interrupted(id) as Exit<A, E>) 100 - } 101 92 onComplete(() => { 102 - forceResolveBlocked = null 103 93 currentCleanup = null 94 + currentOnInterrupt = null 104 95 resolve(run(next())) 105 96 }) 106 97 }) ··· 137 128 )(exitResult), 138 129 interrupt: () => { 139 130 isInterrupted = true 140 - // Cancel the in-flight async operation and force-resume the 141 - // continuation chain so foldEff/bracket handlers still run. 131 + // Cancel the in-flight async operation, then signal interruption 132 + // through the continuation chain so foldEff/bracket handlers run. 142 133 currentCleanup?.() 143 134 currentCleanup = null 144 - forceResolveBlocked?.() 135 + currentOnInterrupt?.() 136 + currentOnInterrupt = null 145 137 }, 146 138 join: (): Eff<A, E, unknown> => 147 139 asyncEff((resume) => {
+6 -17
src/optics/bridges.ts
··· 2 2 * Pre-built optics for purus-ts data types. 3 3 * 4 4 * Bridges connect the optics module to Result, Option, and Validation, 5 - * providing ready-to-use Prisms for each variant and a Traversal for arrays. 5 + * providing ready-to-use Prisms for each variant. 6 6 * 7 7 * @module optics/bridges 8 8 */ 9 9 10 - import type { Result } from "../prelude/result" 11 - import type { Option } from "../prelude/option" 12 10 import type { Validation } from "../data/validation" 13 - import type { Prism, Traversal } from "./types" 14 - import { ok, err } from "../prelude/result" 15 - import { some, none } from "../prelude/option" 16 11 import { valid } from "../data/validation" 12 + import type { Option } from "../prelude/option" 13 + import { none, some } from "../prelude/option" 14 + import type { Result } from "../prelude/result" 15 + import { err, ok } from "../prelude/result" 17 16 import { prism } from "./prism" 18 - import { traversal } from "./traversal" 17 + import type { Prism } from "./types" 19 18 20 19 /** 21 20 * Prism focusing on the Ok variant of a Result. ··· 56 55 (v) => (v._tag === "Valid" ? some(v.value) : none), 57 56 (a) => valid(a), 58 57 ) 59 - 60 - /** 61 - * Traversal focusing on every element of a readonly array. 62 - * getAll returns all elements; modify applies a function to each element. 63 - */ 64 - export const eachTraversal = <A>(): Traversal<readonly A[], A> => 65 - traversal( 66 - (s) => s, 67 - (f) => (s) => s.map(f), 68 - )
+38 -16
tests/effect-basic.test.ts
··· 9 9 Exit, 10 10 ensure, 11 11 fail, 12 + fiberId, 12 13 flatMap, 13 14 foldEff, 14 15 fromPromise, ··· 28 29 tapErr, 29 30 tapError, 30 31 timeout, 32 + yieldNow, 31 33 } from "../src/index" 32 34 33 35 describe("Effect - Basic Operations", () => { ··· 236 238 type Config = { baseUrl: string } 237 239 238 240 it("reads environment and returns an effect", async () => { 239 - const getUrl = accessEff((env: Config) => 240 - succeed(`${env.baseUrl}/users`), 241 - ) 241 + const getUrl = accessEff((env: Config) => succeed(`${env.baseUrl}/users`)) 242 242 const result = await runPromise( 243 - pipe( 244 - getUrl, 245 - provide<Config>({ baseUrl: "https://api.example.com" }), 246 - ), 243 + pipe(getUrl, provide<Config>({ baseUrl: "https://api.example.com" })), 247 244 ) 248 245 expect(result).toBe("https://api.example.com/users") 249 246 }) 250 247 251 248 it("can return a failing effect from environment", async () => { 252 249 const maybeUrl = accessEff((env: Config) => 253 - env.baseUrl === "" 254 - ? fail("empty url" as const) 255 - : succeed(env.baseUrl), 250 + env.baseUrl === "" ? fail("empty url" as const) : succeed(env.baseUrl), 256 251 ) 257 252 const exit = await runPromiseExit( 258 - pipe( 259 - maybeUrl, 260 - provide<Config>({ baseUrl: "" }), 261 - ), 253 + pipe(maybeUrl, provide<Config>({ baseUrl: "" })), 262 254 ) 263 255 expect(Exit.isFailure(exit)).toBe(true) 264 256 if (Exit.isFailure(exit)) expect(exit.error).toBe("empty url") ··· 729 721 730 722 it("retry(0) propagates error without retrying", async () => { 731 723 let attempts = 0 732 - const exit = await runPromiseExit(pipe(attempt(() => { attempts++; throw "boom" }), retry(0))) 724 + const exit = await runPromiseExit( 725 + pipe( 726 + attempt(() => { 727 + attempts++ 728 + throw "boom" 729 + }), 730 + retry(0), 731 + ), 732 + ) 733 733 expect(Exit.isFailure(exit)).toBe(true) 734 734 expect(attempts).toBe(1) 735 735 }) ··· 741 741 return undefined as unknown as number 742 742 }) 743 743 // Use attempt to make it fail 744 - const failing: Eff<number, string, unknown> = flatMap(() => fail("err") as Eff<number, string, unknown>)(alwaysFail) 744 + const failing: Eff<number, string, unknown> = flatMap( 745 + () => fail("err") as Eff<number, string, unknown>, 746 + )(alwaysFail) 745 747 const exit = await runPromiseExit(pipe(failing, retry(3))) 746 748 expect(Exit.isFailure(exit)).toBe(true) 747 749 expect(attempts).toBe(4) ··· 800 802 } 801 803 expect(didThrow).toBe(true) 802 804 expect(released).toContain("before-throw") 805 + }) 806 + }) 807 + 808 + describe("yieldNow", () => { 809 + it("completes successfully after yielding", async () => { 810 + const result = await runPromise( 811 + pipe( 812 + yieldNow, 813 + flatMap(() => succeed(42)), 814 + ), 815 + ) 816 + expect(result).toBe(42) 817 + }) 818 + }) 819 + 820 + describe("fiberId", () => { 821 + it("returns a FiberId with numeric id", async () => { 822 + const fid = await runPromise(fiberId) 823 + expect(fid._tag).toBe("FiberId") 824 + expect(typeof fid.id).toBe("number") 803 825 }) 804 826 }) 805 827 })
+72 -1
tests/effect-concurrency.test.ts
··· 2 2 import { 3 3 all, 4 4 allSequential, 5 + async as asyncEff, 6 + bracket, 5 7 Exit, 6 8 fail, 7 9 flatMap, ··· 419 421 }) 420 422 421 423 it("runs effects and collects results", async () => { 422 - const result = await runPromise(allSequential([succeed(1), succeed(2), succeed(3)])) 424 + const result = await runPromise( 425 + allSequential([succeed(1), succeed(2), succeed(3)]), 426 + ) 423 427 expect(result).toEqual([1, 2, 3]) 424 428 }) 425 429 ··· 504 508 } 505 509 const result = await runPromise(eff) 506 510 expect(result).toBe(1000) 511 + }) 512 + }) 513 + 514 + describe("bracket + interruption", () => { 515 + it("bracket release runs when fiber is interrupted during async use", async () => { 516 + const log: string[] = [] 517 + 518 + const fiber = runFiber( 519 + bracket( 520 + sync(() => { 521 + log.push("acquire") 522 + return "resource" 523 + }), 524 + (r) => 525 + sync(() => { 526 + log.push(`release:${r}`) 527 + }), 528 + (_r) => 529 + asyncEff((resume) => { 530 + log.push("use:start") 531 + const id = setTimeout( 532 + () => resume({ _tag: "Success", value: 42 }), 533 + 500, 534 + ) 535 + return () => { 536 + log.push("use:cleanup") 537 + clearTimeout(id) 538 + } 539 + }), 540 + ), 541 + {}, 542 + ) 543 + 544 + // Let the fiber start and reach the async use phase 545 + await new Promise((r) => setTimeout(r, 30)) 546 + fiber.interrupt() 547 + const exit = await fiber.await() 548 + 549 + expect(Exit.isInterrupted(exit)).toBe(true) 550 + expect(log).toContain("acquire") 551 + expect(log).toContain("use:start") 552 + expect(log).toContain("use:cleanup") 553 + expect(log).toContain("release:resource") 554 + }) 555 + 556 + it("interrupted fiber with bracket produces Interrupted exit (not Failure)", async () => { 557 + const fiber = runFiber( 558 + bracket( 559 + succeed("res"), 560 + (_r) => sync(() => {}), 561 + (_r) => 562 + asyncEff((resume) => { 563 + const id = setTimeout( 564 + () => resume({ _tag: "Success", value: 1 }), 565 + 500, 566 + ) 567 + return () => clearTimeout(id) 568 + }), 569 + ), 570 + {}, 571 + ) 572 + 573 + await new Promise((r) => setTimeout(r, 20)) 574 + fiber.interrupt() 575 + const exit = await fiber.await() 576 + expect(Exit.isInterrupted(exit)).toBe(true) 577 + expect(Exit.isFailure(exit)).toBe(false) 507 578 }) 508 579 }) 509 580 })
+292 -26
tests/optics.test.ts
··· 1 1 import { describe, expect, it } from "bun:test" 2 2 import { 3 - lens, view, set, over, prop, 4 - prism, preview, review, 5 - optional, getOption, setOptional, modifyOptional, index, 6 - traversal, getAll, modifyTraversal, each, 7 3 compose, 8 - okPrism, errPrism, somePrism, validPrism, eachTraversal, 9 - some, none, isSome, ok, err, valid, invalid, 4 + each, 5 + err, 6 + errPrism, 7 + getAll, 8 + getOption, 9 + index, 10 + invalid, 11 + isSome, 12 + lens, 13 + modifyOptional, 14 + modifyTraversal, 15 + none, 16 + ok, 17 + okPrism, 18 + optional, 19 + over, 20 + preview, 21 + prism, 22 + prop, 23 + review, 24 + set, 25 + setOptional, 26 + some, 27 + somePrism, 28 + traversal, 29 + valid, 30 + validPrism, 31 + view, 10 32 } from "../src/index" 11 33 12 34 type User = { name: string; age: number } ··· 37 59 }) 38 60 it("set-set law: set(b)(set(a)(s)) deep-equals set(b)(s)", () => { 39 61 const s: User = { name: "Alice", age: 30 } 40 - expect(set(nameLens)("C")(set(nameLens)("B")(s))).toEqual(set(nameLens)("C")(s)) 62 + expect(set(nameLens)("C")(set(nameLens)("B")(s))).toEqual( 63 + set(nameLens)("C")(s), 64 + ) 41 65 expect(set(ageLens)(5)(set(ageLens)(1)(s))).toEqual(set(ageLens)(5)(s)) 42 66 }) 43 67 }) ··· 46 70 expect(view(nameLens)({ name: "Alice", age: 30 })).toBe("Alice") 47 71 }) 48 72 it("set replaces the focused value", () => { 49 - expect(set(nameLens)("Bob")({ name: "Alice", age: 30 })).toEqual({ name: "Bob", age: 30 }) 73 + expect(set(nameLens)("Bob")({ name: "Alice", age: 30 })).toEqual({ 74 + name: "Bob", 75 + age: 30, 76 + }) 50 77 }) 51 78 it("over modifies the focused value with a function", () => { 52 - expect(over(nameLens)((n) => n.toUpperCase())({ name: "alice", age: 30 })) 53 - .toEqual({ name: "ALICE", age: 30 }) 79 + expect( 80 + over(nameLens)((n) => n.toUpperCase())({ name: "alice", age: 30 }), 81 + ).toEqual({ name: "ALICE", age: 30 }) 54 82 }) 55 83 }) 56 84 describe("prop()", () => { ··· 69 97 }) 70 98 }) 71 99 72 - type Shape = { _tag: "Circle"; radius: number } | { _tag: "Square"; side: number } 100 + type Shape = 101 + | { _tag: "Circle"; radius: number } 102 + | { _tag: "Square"; side: number } 73 103 const circlePrism = prism<Shape, number>( 74 - (s) => s._tag === "Circle" ? some(s.radius) : none, 104 + (s) => (s._tag === "Circle" ? some(s.radius) : none), 75 105 (radius) => ({ _tag: "Circle", radius }), 76 106 ) 77 107 ··· 102 132 103 133 describe("Optional", () => { 104 134 const headOpt = optional<readonly number[], number>( 105 - (arr) => arr.length > 0 ? some(arr[0] as number) : none, 106 - (a) => (arr) => arr.length > 0 ? [a, ...arr.slice(1)] : arr, 135 + (arr) => (arr.length > 0 ? some(arr[0] as number) : none), 136 + (a) => (arr) => (arr.length > 0 ? [a, ...arr.slice(1)] : arr), 107 137 ) 108 138 it("getOption returns Some for existing element", () => { 109 139 expect(getOption(headOpt)([10, 20])).toEqual(some(10)) ··· 149 179 expect(getAll(each<number>())([])).toEqual([]) 150 180 }) 151 181 it("modifyTraversal applies function to every element", () => { 152 - expect(modifyTraversal(each<number>())((n) => n * 2)([1, 2, 3])).toEqual([2, 4, 6]) 182 + expect(modifyTraversal(each<number>())((n) => n * 2)([1, 2, 3])).toEqual([ 183 + 2, 4, 6, 184 + ]) 153 185 }) 154 186 }) 155 187 it("custom traversal getAll/modify work on arrays", () => { ··· 158 190 (f) => (s) => s.map(f), 159 191 ) 160 192 expect(getAll(trav)(["a", "b"])).toEqual(["a", "b"]) 161 - expect(modifyTraversal(trav)((x) => x.toUpperCase())(["a", "b"])).toEqual(["A", "B"]) 193 + expect(modifyTraversal(trav)((x) => x.toUpperCase())(["a", "b"])).toEqual([ 194 + "A", 195 + "B", 196 + ]) 162 197 }) 163 198 }) 164 199 ··· 166 201 type Outer = { inner: User } 167 202 const outerNameLens = compose(prop<Outer>()("inner"), prop<User>()("name")) 168 203 it("compose(lens, lens): view through two levels of nesting", () => { 169 - expect(view(outerNameLens)({ inner: { name: "Alice", age: 30 } })).toBe("Alice") 204 + expect(view(outerNameLens)({ inner: { name: "Alice", age: 30 } })).toBe( 205 + "Alice", 206 + ) 170 207 }) 171 208 it("compose(lens, lens): set through two levels of nesting", () => { 172 - expect(set(outerNameLens)("Bob")({ inner: { name: "Alice", age: 30 } })) 173 - .toEqual({ inner: { name: "Bob", age: 30 } }) 209 + expect( 210 + set(outerNameLens)("Bob")({ inner: { name: "Alice", age: 30 } }), 211 + ).toEqual({ inner: { name: "Bob", age: 30 } }) 174 212 }) 175 213 it("compose(lens, prism): produces a working optional (getOption Some on match)", () => { 176 214 type Container = { shape: Shape } ··· 186 224 }) 187 225 it("compose(optional, prism): set works correctly (regression)", () => { 188 226 const headOpt = optional<readonly Shape[], Shape>( 189 - (arr) => arr.length > 0 ? some(arr[0] as Shape) : none, 190 - (a) => (arr) => arr.length > 0 ? [a, ...arr.slice(1)] : arr, 227 + (arr) => (arr.length > 0 ? some(arr[0] as Shape) : none), 228 + (a) => (arr) => (arr.length > 0 ? [a, ...arr.slice(1)] : arr), 191 229 ) 192 230 const composed = compose(headOpt, circlePrism) 193 231 const shapes: readonly Shape[] = [{ _tag: "Circle", radius: 5 }] 194 - expect(setOptional(composed)(99)(shapes)).toEqual([{ _tag: "Circle", radius: 99 }]) 232 + expect(setOptional(composed)(99)(shapes)).toEqual([ 233 + { _tag: "Circle", radius: 99 }, 234 + ]) 195 235 }) 196 236 }) 197 237 ··· 234 274 expect(preview(validPrism<number, string>())(valid(42))).toEqual(some(42)) 235 275 }) 236 276 it("preview on invalid(['e']) returns None", () => { 237 - expect(preview(validPrism<number, string>())(invalid(["e"]))).toEqual(none) 277 + expect(preview(validPrism<number, string>())(invalid(["e"]))).toEqual( 278 + none, 279 + ) 238 280 }) 239 281 it("review(42) returns valid(42)", () => { 240 282 expect(review(validPrism<number, string>())(42)).toEqual(valid(42)) 241 283 }) 242 284 }) 243 - describe("eachTraversal", () => { 285 + describe("each (from traversal)", () => { 244 286 it("getAll on [1,2,3] returns [1,2,3]", () => { 245 - expect(getAll(eachTraversal<number>())([1, 2, 3])).toEqual([1, 2, 3]) 287 + expect(getAll(each<number>())([1, 2, 3])).toEqual([1, 2, 3]) 246 288 }) 247 289 it("modifyTraversal(x => x*2) on [1,2,3] returns [2,4,6]", () => { 248 - expect(modifyTraversal(eachTraversal<number>())((x) => x * 2)([1, 2, 3])).toEqual([2, 4, 6]) 290 + expect(modifyTraversal(each<number>())((x) => x * 2)([1, 2, 3])).toEqual([ 291 + 2, 4, 6, 292 + ]) 293 + }) 294 + }) 295 + }) 296 + 297 + describe("Optional laws", () => { 298 + const headOpt = optional<readonly number[], number>( 299 + (arr) => (arr.length > 0 ? some(arr[0] as number) : none), 300 + (a) => (arr) => (arr.length > 0 ? [a, ...arr.slice(1)] : arr), 301 + ) 302 + 303 + it("getOption-set law: setOptional(a)(s) where getOption(s)=Some(a) restores s", () => { 304 + const s = [10, 20, 30] 305 + const got = getOption(headOpt)(s) 306 + if (isSome(got)) { 307 + expect(setOptional(headOpt)(got.value)(s)).toEqual(s) 308 + } 309 + }) 310 + 311 + it("set-getOption law: getOption(setOptional(a)(s)) === Some(a) when focus exists", () => { 312 + const s = [10, 20, 30] 313 + expect(getOption(headOpt)(setOptional(headOpt)(99)(s))).toEqual(some(99)) 314 + }) 315 + 316 + it("getOption-set law holds for index()", () => { 317 + const idx1 = index<number>(1) 318 + const s = [10, 20, 30] 319 + const got = getOption(idx1)(s) 320 + if (isSome(got)) { 321 + expect(setOptional(idx1)(got.value)(s)).toEqual(s) 322 + } 323 + }) 324 + 325 + it("set-getOption law holds for index()", () => { 326 + const idx1 = index<number>(1) 327 + const s = [10, 20, 30] 328 + expect(getOption(idx1)(setOptional(idx1)(99)(s))).toEqual(some(99)) 329 + }) 330 + 331 + it("set is no-op when focus absent (empty array)", () => { 332 + expect(setOptional(headOpt)(42)([])).toEqual([]) 333 + }) 334 + }) 335 + 336 + describe("Traversal laws", () => { 337 + const nums = each<number>() 338 + 339 + it("identity law: modifyTraversal(id)(s) === s", () => { 340 + const s = [1, 2, 3] 341 + expect(modifyTraversal(nums)((x) => x)(s)).toEqual(s) 342 + }) 343 + 344 + it("identity law on empty array", () => { 345 + expect(modifyTraversal(nums)((x) => x)([])).toEqual([]) 346 + }) 347 + 348 + it("composition law: modify(f)(modify(g)(s)) === modify(f . g)(s)", () => { 349 + const s = [1, 2, 3] 350 + const f = (x: number) => x * 2 351 + const g = (x: number) => x + 10 352 + const composed = (x: number) => f(g(x)) 353 + expect(modifyTraversal(nums)(f)(modifyTraversal(nums)(g)(s))).toEqual( 354 + modifyTraversal(nums)(composed)(s), 355 + ) 356 + }) 357 + }) 358 + 359 + describe("compose — additional overloads", () => { 360 + describe("compose(prism, lens)", () => { 361 + // Prism<Shape, number> + Lens<number, string> — but we need a lens from number 362 + // Use: Prism<Option<User>, User> + Lens<User, string> 363 + const composed = compose(somePrism<User>(), prop<User>()("name")) 364 + 365 + it("getOption returns Some when prism matches", () => { 366 + expect(getOption(composed)(some({ name: "Alice", age: 30 }))).toEqual( 367 + some("Alice"), 368 + ) 369 + }) 370 + it("getOption returns None when prism doesn't match", () => { 371 + expect(getOption(composed)(none)).toEqual(none) 372 + }) 373 + it("set updates through prism then lens", () => { 374 + expect( 375 + setOptional(composed)("Bob")(some({ name: "Alice", age: 30 })), 376 + ).toEqual(some({ name: "Bob", age: 30 })) 377 + }) 378 + }) 379 + 380 + describe("compose(prism, prism)", () => { 381 + // Result prism into Option prism 382 + const composed = compose( 383 + okPrism<{ _tag: "Some"; value: number } | { _tag: "None" }, string>(), 384 + somePrism<number>(), 385 + ) 386 + 387 + it("preview through two prisms (both match)", () => { 388 + expect(preview(composed)(ok(some(42)))).toEqual(some(42)) 389 + }) 390 + it("preview returns None when outer doesn't match", () => { 391 + expect(preview(composed)(err("e"))).toEqual(none) 392 + }) 393 + it("preview returns None when inner doesn't match", () => { 394 + expect(preview(composed)(ok(none))).toEqual(none) 395 + }) 396 + it("review composes correctly", () => { 397 + expect(review(composed)(42)).toEqual(ok(some(42))) 398 + }) 399 + }) 400 + 401 + describe("compose(lens, optional)", () => { 402 + type HasItems = { items: readonly number[] } 403 + const itemsLens = prop<HasItems>()("items") 404 + const composed = compose(itemsLens, index<number>(0)) 405 + 406 + it("getOption returns Some when focus exists", () => { 407 + expect(getOption(composed)({ items: [10, 20] })).toEqual(some(10)) 408 + }) 409 + it("getOption returns None when focus absent", () => { 410 + expect(getOption(composed)({ items: [] })).toEqual(none) 411 + }) 412 + it("set updates element through lens + optional", () => { 413 + expect(setOptional(composed)(99)({ items: [10, 20] })).toEqual({ 414 + items: [99, 20], 415 + }) 416 + }) 417 + }) 418 + 419 + describe("compose(optional, lens)", () => { 420 + const headUser = optional<readonly User[], User>( 421 + (arr) => (arr.length > 0 ? some(arr[0] as User) : none), 422 + (u) => (arr) => (arr.length > 0 ? [u, ...arr.slice(1)] : arr), 423 + ) 424 + const composed = compose(headUser, prop<User>()("name")) 425 + 426 + it("getOption returns Some for non-empty array", () => { 427 + expect(getOption(composed)([{ name: "Alice", age: 30 }])).toEqual( 428 + some("Alice"), 429 + ) 430 + }) 431 + it("getOption returns None for empty array", () => { 432 + expect(getOption(composed)([])).toEqual(none) 433 + }) 434 + it("set updates focused element's property", () => { 435 + expect( 436 + setOptional(composed)("Bob")([{ name: "Alice", age: 30 }]), 437 + ).toEqual([{ name: "Bob", age: 30 }]) 438 + }) 439 + }) 440 + 441 + describe("compose(optional, optional)", () => { 442 + const firstArr = optional< 443 + readonly (readonly number[])[], 444 + readonly number[] 445 + >( 446 + (arr) => (arr.length > 0 ? some(arr[0] as readonly number[]) : none), 447 + (a) => (arr) => (arr.length > 0 ? [a, ...arr.slice(1)] : arr), 448 + ) 449 + const composed = compose(firstArr, index<number>(0)) 450 + 451 + it("getOption through two optionals", () => { 452 + expect(getOption(composed)([[10, 20], [30]])).toEqual(some(10)) 453 + }) 454 + it("getOption returns None when outer absent", () => { 455 + expect(getOption(composed)([])).toEqual(none) 456 + }) 457 + it("getOption returns None when inner absent", () => { 458 + expect(getOption(composed)([[]])).toEqual(none) 459 + }) 460 + }) 461 + 462 + describe("compose(lens, traversal)", () => { 463 + type HasNums = { nums: readonly number[] } 464 + const numsLens = prop<HasNums>()("nums") 465 + const composed = compose(numsLens, each<number>()) 466 + 467 + it("getAll through lens into traversal", () => { 468 + expect(getAll(composed)({ nums: [1, 2, 3] })).toEqual([1, 2, 3]) 469 + }) 470 + it("modify through lens into traversal", () => { 471 + expect( 472 + modifyTraversal(composed)((n) => n * 10)({ nums: [1, 2] }), 473 + ).toEqual({ nums: [10, 20] }) 474 + }) 475 + }) 476 + 477 + describe("compose(traversal, lens)", () => { 478 + const usersTraversal = each<User>() 479 + const composed = compose(usersTraversal, prop<User>()("name")) 480 + 481 + it("getAll extracts all names", () => { 482 + const users: readonly User[] = [ 483 + { name: "Alice", age: 30 }, 484 + { name: "Bob", age: 25 }, 485 + ] 486 + expect(getAll(composed)(users)).toEqual(["Alice", "Bob"]) 487 + }) 488 + it("modify updates all names", () => { 489 + const users: readonly User[] = [ 490 + { name: "alice", age: 30 }, 491 + { name: "bob", age: 25 }, 492 + ] 493 + expect(modifyTraversal(composed)((n) => n.toUpperCase())(users)).toEqual([ 494 + { name: "ALICE", age: 30 }, 495 + { name: "BOB", age: 25 }, 496 + ]) 497 + }) 498 + }) 499 + 500 + describe("compose(prism, optional)", () => { 501 + // okPrism into index 502 + const composed = compose( 503 + okPrism<readonly number[], string>(), 504 + index<number>(0), 505 + ) 506 + 507 + it("getOption returns Some when both match", () => { 508 + expect(getOption(composed)(ok([10, 20]))).toEqual(some(10)) 509 + }) 510 + it("getOption returns None when prism doesn't match", () => { 511 + expect(getOption(composed)(err("e"))).toEqual(none) 512 + }) 513 + it("getOption returns None when optional doesn't match", () => { 514 + expect(getOption(composed)(ok([]))).toEqual(none) 249 515 }) 250 516 }) 251 517 })
+4 -4
tests/validation.test.ts
··· 101 101 expect(result).toEqual(invalid(["error1", "error2"])) 102 102 }) 103 103 104 - it("accumulates empty error arrays when both invalid with no errors", () => { 105 - const vf = invalid<string>([]) 106 - const va = invalid<string>([]) 104 + it("accumulates errors from both sides when both invalid", () => { 105 + const vf = invalid<string>(["left"]) 106 + const va = invalid<string>(["right"]) 107 107 const result = pipe(vf, apValidation(va)) 108 - expect(result).toEqual(invalid([])) 108 + expect(result).toEqual(invalid(["left", "right"])) 109 109 }) 110 110 111 111 it("accumulates multiple errors from multiple validations", () => {