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 critical bugs, harden effect system, correct docs, and add 39 tests

+542 -61
+4 -3
docs-site/src/content/docs/concepts/00-why-these-strange-names.md
··· 150 150 - **Interrupt**: "Stop! We're making pizza instead" (cancel the work) 151 151 152 152 ```typescript 153 - const fiber = yield* fork(longComputation) // Start in background 154 - // ... do other work ... 155 - const result = yield* join(fiber) // Get the result when needed 153 + const result = pipe( 154 + fork(longComputation), // Start in background 155 + flatMap(fiber => join(fiber)) // Get the result when needed 156 + ) 156 157 ``` 157 158 158 159 ---
+1 -1
docs-site/src/content/docs/concepts/06-effect-composition.md
··· 348 348 fetchOrders(id), 349 349 fetchPreferences(id) 350 350 ]) 351 - // Type: Eff<readonly [User, Order[], Preferences], Error, unknown> 351 + // Type: Eff<readonly (User | Order[] | Preferences)[], Error, unknown> 352 352 ``` 353 353 354 354 ### Racing
+4 -4
docs-site/src/content/docs/concepts/09-testing-strategies.md
··· 267 267 const fast = pipe(sleep(10), mapEff(() => "fast")) 268 268 const slow = pipe(sleep(100), mapEff(() => "slow")) 269 269 270 - const exit = await runPromiseExit(race([fast, slow])) 270 + const exit = await runPromiseExit(race(fast, slow)) 271 271 272 272 expect(exit._tag).toBe("Success") 273 273 expect(exit.value).toBe("fast") ··· 330 330 ```typescript 331 331 import { timeout } from "purus-ts" 332 332 333 - test("timeout fails slow effects", async () => { 333 + test("timeout returns null for slow effects", async () => { 334 334 const slow = pipe(sleep(1000), mapEff(() => "done")) 335 335 const withTimeout = timeout(50)(slow) 336 336 337 337 const exit = await runPromiseExit(withTimeout) 338 338 339 - expect(exit._tag).toBe("Failure") 340 - expect(exit.error._tag).toBe("Timeout") 339 + expect(exit._tag).toBe("Success") 340 + expect(exit.value).toBe(null) 341 341 }) 342 342 343 343 test("timeout allows fast effects", async () => {
+1 -1
docs-site/src/content/docs/examples/index.md
··· 42 42 43 43 Background job processing with real cancellation and dependency injection. 44 44 45 - **Key concepts:** `fork`/`join`, `catchAll`, `provide` for DI 45 + **Key concepts:** `allSequential`, `timeout`, `retry`, `catchAll`, `provide` for DI 46 46 47 47 ```bash 48 48 bun run examples/task-queue/without-purus.ts
+1 -1
docs-site/src/content/docs/stories/beavers-big-system/index.md
··· 95 95 96 96 - [Stories overview](../) — Other available stories 97 97 - [The Forest Election](../forest-election/) — The prequel trilogy 98 - - [Branded Types and Refinements](/concepts/01-branded-types-and-refinements/) — Technical deep-dive 98 + - [Branded Types In Depth](/concepts/02-branded-types/) — Technical deep-dive
-2
docs-site/src/content/docs/stories/forest-election/03-the-announcement.mdx
··· 50 50 race, 51 51 retry, 52 52 all, 53 - fork, 54 - join, 55 53 } from "purus-ts" 56 54 ``` 57 55
+8 -8
examples/http-client/with-purus.ts
··· 213 213 ), 214 214 // RECOVERY: catchAll transforms errors into success values 215 215 // The error is typed, so we know exactly what we're catching 216 - catchAll((error) => ( 217 - console.log(`[Caught] ${handleError(error)}`), 218 - succeed({ id: 0, name: "Timeout Fallback", email: "" }) 219 - )) 216 + catchAll((error) => { 217 + console.log(`[Caught] ${handleError(error)}`) 218 + return succeed({ id: 0, name: "Timeout Fallback", email: "" }) 219 + }) 220 220 ) 221 221 222 222 const fallbackUser = await runPromise(request2) ··· 231 231 const request3 = pipe( 232 232 fetchUser("https://jsonplaceholder.typicode.com/users/99999"), 233 233 retry(0), // No retries for this test 234 - catchAll((error) => ( 235 - console.log(`[Caught] ${handleError(error)}`), 236 - succeed({ id: 0, name: "Default User", email: "" }) 237 - )) 234 + catchAll((error) => { 235 + console.log(`[Caught] ${handleError(error)}`) 236 + return succeed({ id: 0, name: "Default User", email: "" }) 237 + }) 238 238 ) 239 239 240 240 const user404 = await runPromise(request3)
+3 -3
examples/stories/forest-election/03-effects.ts
··· 90 90 if (roll > reliabilityPercent) { 91 91 const reason = 92 92 DISTRACTIONS[Math.floor(Math.random() * DISTRACTIONS.length)]! 93 - return fail({ 93 + return fail<MessageError>({ 94 94 _tag: "BirdDistracted", 95 95 bird, 96 96 reason, 97 - } as MessageError) 97 + }) 98 98 } 99 99 100 100 // Bird gets lost (5% chance even for reliable birds) 101 101 if (roll > reliabilityPercent - 5) { 102 - return fail({ _tag: "BirdLost", bird } as MessageError) 102 + return fail<MessageError>({ _tag: "BirdLost", bird }) 103 103 } 104 104 105 105 // Success!
+12 -12
examples/task-queue/with-purus.ts
··· 194 194 accessEff((env: QueueEnv) => 195 195 pipe( 196 196 succeed(undefined), 197 - flatMap(() => ( 198 - env.logger.info(`Processing job ${job.id} (${job.type})`), 199 - executeJob(job) 200 - )) 197 + flatMap(() => { 198 + env.logger.info(`Processing job ${job.id} (${job.type})`) 199 + return executeJob(job) 200 + }) 201 201 ) 202 202 ), 203 203 ··· 217 217 // Error recovery - log and continue 218 218 // Note: error is JobError, not unknown - we know exactly what failed 219 219 catchAll((error: JobError) => 220 - accessEff((env: QueueEnv) => ( 220 + accessEff((env: QueueEnv) => { 221 221 env.logger.error( 222 222 match(error)({ 223 223 TimeoutError: ({ jobId, ms }) => `Job ${jobId} timed out after ${ms}ms`, 224 224 TransientError: ({ jobId, message }) => `Job ${jobId} failed: ${message}`, 225 225 }) 226 - ), 227 - succeed(undefined) 228 - )) 226 + ) 227 + return succeed(undefined) 228 + }) 229 229 ), 230 230 231 231 // Final log 232 232 flatMap(() => 233 - accessEff((env: QueueEnv) => ( 234 - env.logger.info(`Job ${job.id} completed`), 235 - succeed(undefined) 236 - )) 233 + accessEff((env: QueueEnv) => { 234 + env.logger.info(`Job ${job.id} completed`) 235 + return succeed(undefined) 236 + }) 237 237 ) 238 238 ) 239 239
+3 -3
src/data/guards.ts
··· 60 60 // TS 5.5+ infers these — `x !== undefined` is a single-step narrowing. 61 61 62 62 /** Narrows `T | undefined` to `T`. Use with `.filter(isDefined)` or in pipes. */ 63 - export const isDefined = <T>(x: T | undefined) => x !== undefined 63 + export const isDefined = <T>(x: T | undefined): x is T => x !== undefined 64 64 65 65 /** Narrows `T | null` to `T`. */ 66 - export const isNotNull = <T>(x: T | null) => x !== null 66 + export const isNotNull = <T>(x: T | null): x is T => x !== null 67 67 68 68 /** Narrows `T | null | undefined` to `T`. */ 69 - export const isNotNullish = <T>(x: T | null | undefined) => 69 + export const isNotNullish = <T>(x: T | null | undefined): x is NonNullable<T> => 70 70 x !== null && x !== undefined 71 71 72 72 // =============================================================================
+15 -5
src/effect/combinators.ts
··· 10 10 fork, 11 11 mapEff, 12 12 succeed, 13 + sync, 13 14 } from "./eff" 14 15 import { type Exit, failure, interrupted, success } from "./exit" 15 16 ··· 350 351 flatMap((fiber1) => 351 352 pipe( 352 353 eff2 as Eff<B, E1 | E2, R1 | R2>, 353 - flatMap((b) => 354 - pipe( 355 - join(fiber1), 356 - mapEff((a): readonly [A, B] => [a, b] as const), 357 - ), 354 + foldEff( 355 + (e: E1 | E2) => 356 + // Interrupt fiber1 to prevent leak, then propagate error 357 + pipe( 358 + sync(() => fiber1.interrupt()) as Eff<void, E1 | E2, R1 | R2>, 359 + flatMap( 360 + () => fail(e) as Eff<readonly [A, B], E1 | E2, R1 | R2>, 361 + ), 362 + ), 363 + (b: B) => 364 + pipe( 365 + join(fiber1) as Eff<A, E1 | E2, R1 | R2>, 366 + mapEff((a): readonly [A, B] => [a, b] as const), 367 + ), 358 368 ), 359 369 ), 360 370 ),
+5 -3
src/effect/interpret.ts
··· 1 1 import { match } from "../prelude/match" 2 2 import type { Eff, Fiber, FiberId } from "./eff" 3 3 import { makeFiberId } from "./eff" 4 - import { type Exit, matchExit } from "./exit" 5 - import { blocked, type Step, suspended } from "./step" 4 + import { type Exit, interrupted, matchExit } from "./exit" 5 + import { blocked, done, type Step, suspended } from "./step" 6 6 7 7 /** 8 8 * Continuation - what to do with the result of an effect. ··· 62 62 (cb) => { 63 63 callback = cb 64 64 }, 65 - () => handleExit(result!), 65 + // result is set by the async callback before onComplete fires; 66 + // if it's somehow null (e.g. early cleanup), treat as interrupted. 67 + () => (result !== null ? handleExit(result) : done(interrupted("unknown"))), 66 68 ) 67 69 }, 68 70
+53 -2
src/effect/trampoline.ts
··· 51 51 52 52 /** 53 53 * Create a fiber from an effect. 54 + * 55 + * Uses an interrupt-aware trampoline that: 56 + * - Checks the interrupt flag at each Suspended/Blocked step 57 + * - Calls the current async cleanup when interrupted 58 + * - Short-circuits to an Interrupted exit when the flag is set 54 59 */ 55 60 const createFiber = <A, E, R>(eff: Eff<A, E, R>, env: R): Fiber<A, E> => { 56 61 const id = `fiber-${++fiberCounter}` ··· 58 63 let isInterrupted = false 59 64 let exitResult: Option<Exit<A, E>> = none 60 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. 70 + let currentCleanup: (() => void) | null = null 71 + let forceResolveBlocked: (() => void) | null = null 61 72 62 73 const makeFiberFn = <A2, E2>(e: Eff<A2, E2, R>, r: R) => createFiber(e, r) 63 74 75 + // Interrupt-aware trampoline: when interrupted during a Blocked step, 76 + // calls the async cleanup and force-resumes through the normal 77 + // continuation chain. This ensures foldEff/bracket handlers still run. 78 + const run = (step: Step<A, E>): Promise<Exit<A, E>> => 79 + match(step)({ 80 + Done: ({ exit }) => { 81 + currentCleanup = null 82 + forceResolveBlocked = null 83 + return Promise.resolve(exit) 84 + }, 85 + 86 + Suspended: ({ resume }) => 87 + Promise.resolve().then(() => run(resume())), 88 + 89 + Blocked: ({ cleanup, onComplete, next }) => { 90 + currentCleanup = cleanup 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 + onComplete(() => { 102 + forceResolveBlocked = null 103 + currentCleanup = null 104 + resolve(run(next())) 105 + }) 106 + }) 107 + }, 108 + }) 109 + 64 110 const initialStep = interpret( 65 111 eff, 66 112 { ··· 72 118 makeFiberFn, 73 119 ) as Step<A, E> 74 120 75 - // Start execution 76 - trampoline(initialStep).then((exit) => { 121 + // Start execution using the interrupt-aware trampoline 122 + run(initialStep).then((exit) => { 77 123 const finalExit: Exit<A, E> = isInterrupted 78 124 ? (interrupted(id) as Exit<A, E>) 79 125 : exit ··· 91 137 )(exitResult), 92 138 interrupt: () => { 93 139 isInterrupted = true 140 + // Cancel the in-flight async operation and force-resume the 141 + // continuation chain so foldEff/bracket handlers still run. 142 + currentCleanup?.() 143 + currentCleanup = null 144 + forceResolveBlocked?.() 94 145 }, 95 146 join: (): Eff<A, E, unknown> => 96 147 asyncEff((resume) => {
+4 -4
src/prelude/compose.ts
··· 90 90 * @example 91 91 * ```typescript 92 92 * const result = ifElse(true)( 93 - * () => "was false", 94 - * () => "was true" 93 + * () => "was true", 94 + * () => "was false" 95 95 * ) 96 96 * // Returns: "was true" 97 97 * ``` 98 98 */ 99 99 export const ifElse = 100 100 <T, E>(predicate: boolean) => 101 - (onFalse: () => T | E, onTrue: () => T | E): T | E => 102 - predicate ? onFalse() : onTrue() 101 + (onTrue: () => T | E, onFalse: () => T | E): T | E => 102 + predicate ? onTrue() : onFalse() 103 103 104 104 /** 105 105 * Pipe a value through a series of transformations.
+23 -7
src/prelude/match.ts
··· 187 187 * Unlike match() which works on objects with _tag, this matches 188 188 * primitive literal values directly. 189 189 * 190 + * Exhaustive form requires a handler for every literal in the union. 191 + * Partial form requires a defaultCase for unhandled values. 192 + * 190 193 * @example 191 194 * ```typescript 192 195 * type Direction = "north" | "south" | "east" | "west" ··· 202 205 * 203 206 * @example 204 207 * ```typescript 205 - * // With a default case: 206 - * const dayType = matchLiteral(day)({ 208 + * // With a default case for partial matches: 209 + * const dayType = matchLiteralOr(day)("weekday")({ 207 210 * saturday: "weekend", 208 211 * sunday: "weekend", 209 - * }, "weekday") 212 + * }) 210 213 * ``` 211 214 */ 212 215 export const matchLiteral = 213 216 <T extends string | number | boolean>(value: T) => 214 - <R>(cases: { [K in T & PropertyKey]: R }, defaultCase?: R): R => 215 - (value as PropertyKey) in cases 216 - ? cases[value as T & PropertyKey] 217 - : (defaultCase as R) 217 + <R>(cases: { [K in T & PropertyKey]: R }): R => 218 + cases[value as T & PropertyKey] 219 + 220 + /** 221 + * Match on literal values with a default for unhandled cases. 222 + * 223 + * Unlike matchLiteral() which requires exhaustive cases, 224 + * this allows partial matches with a fallback value. 225 + */ 226 + export const matchLiteralOr = 227 + <T extends string | number | boolean>(value: T) => 228 + <R>(defaultCase: R) => 229 + (cases: Partial<{ [K in T & PropertyKey]: R }>): R => { 230 + // Safe: `in` check guarantees key exists; Partial makes type R | undefined but runtime value is R 231 + const record = cases as Record<PropertyKey, R> 232 + return (value as PropertyKey) in cases ? record[value as PropertyKey]! : defaultCase 233 + }
+1 -1
src/prelude/result.ts
··· 256 256 const results: B[] = [] 257 257 for (const a of as) { 258 258 const r = f(a) 259 - if (r._tag === "Err") return r as unknown as Result<readonly B[], E> 259 + if (r._tag === "Err") return r 260 260 results.push(r.value) 261 261 } 262 262 return ok(results)
+36 -1
tests/composition.test.ts
··· 1 1 import { describe, expect, it } from "bun:test" 2 - import { constant, flip, flow, id, pipe, tap } from "../src/index" 2 + import { constant, flip, flow, id, ifElse, pipe, tap } from "../src/index" 3 3 4 4 describe("Composition", () => { 5 5 describe("pipe", () => { ··· 53 53 })(42) 54 54 expect(result).toBe(42) 55 55 expect(sideEffect).toBe(42) 56 + }) 57 + }) 58 + 59 + describe("ifElse", () => { 60 + it("calls onTrue when predicate is true", () => { 61 + const result = ifElse(true)( 62 + () => "was true", 63 + () => "was false", 64 + ) 65 + expect(result).toBe("was true") 66 + }) 67 + 68 + it("calls onFalse when predicate is false", () => { 69 + const result = ifElse(false)( 70 + () => "was true", 71 + () => "was false", 72 + ) 73 + expect(result).toBe("was false") 74 + }) 75 + 76 + it("only evaluates the chosen branch", () => { 77 + let trueCalled = false 78 + let falseCalled = false 79 + ifElse(true)( 80 + () => { 81 + trueCalled = true 82 + return "t" 83 + }, 84 + () => { 85 + falseCalled = true 86 + return "f" 87 + }, 88 + ) 89 + expect(trueCalled).toBe(true) 90 + expect(falseCalled).toBe(false) 56 91 }) 57 92 }) 58 93 })
+65
tests/effect-basic.test.ts
··· 1 1 import { describe, expect, it } from "bun:test" 2 2 import { 3 3 access, 4 + accessEff, 4 5 attempt, 5 6 bracket, 6 7 catchAll, ··· 19 20 succeed, 20 21 sync, 21 22 tapBoth, 23 + tapEff, 22 24 tapEffect, 23 25 tapErr, 24 26 tapError, ··· 222 224 ), 223 225 ) 224 226 expect(result).toBe(42) 227 + expect(called).toBe(false) 228 + }) 229 + }) 230 + 231 + describe("accessEff", () => { 232 + type Config = { baseUrl: string } 233 + 234 + it("reads environment and returns an effect", async () => { 235 + const getUrl = accessEff((env: Config) => 236 + succeed(`${env.baseUrl}/users`), 237 + ) 238 + const result = await runPromise( 239 + pipe( 240 + getUrl, 241 + provide<Config>({ baseUrl: "https://api.example.com" }), 242 + ), 243 + ) 244 + expect(result).toBe("https://api.example.com/users") 245 + }) 246 + 247 + it("can return a failing effect from environment", async () => { 248 + const maybeUrl = accessEff((env: Config) => 249 + env.baseUrl === "" 250 + ? fail("empty url" as const) 251 + : succeed(env.baseUrl), 252 + ) 253 + const exit = await runPromiseExit( 254 + pipe( 255 + maybeUrl, 256 + provide<Config>({ baseUrl: "" }), 257 + ), 258 + ) 259 + expect(Exit.isFailure(exit)).toBe(true) 260 + if (Exit.isFailure(exit)) expect(exit.error).toBe("empty url") 261 + }) 262 + }) 263 + 264 + describe("tapEff", () => { 265 + it("runs side effect on success and returns original value", async () => { 266 + let observed: number | null = null 267 + const result = await runPromise( 268 + pipe( 269 + succeed(42), 270 + tapEff((a: number) => { 271 + observed = a 272 + }), 273 + ), 274 + ) 275 + expect(result).toBe(42) 276 + expect(observed as unknown as number).toBe(42) 277 + }) 278 + 279 + it("does not run on failure", async () => { 280 + let called = false 281 + const exit = await runPromiseExit( 282 + pipe( 283 + fail("boom") as Eff<number, string, unknown>, 284 + tapEff((_a: number) => { 285 + called = true 286 + }), 287 + ), 288 + ) 289 + expect(Exit.isFailure(exit)).toBe(true) 225 290 expect(called).toBe(false) 226 291 }) 227 292 })
+54
tests/effect-concurrency.test.ts
··· 5 5 fail, 6 6 flatMap, 7 7 fork, 8 + interruptFiber, 8 9 join, 9 10 mapEff, 10 11 pipe, 11 12 race, 13 + repeatEff, 12 14 retry, 13 15 runFiber, 14 16 runPromise, ··· 17 19 sequencePar, 18 20 sleep, 19 21 succeed, 22 + sync, 20 23 timeout, 21 24 traverse, 22 25 traversePar, ··· 309 312 ) 310 313 expect(Exit.isFailure(exit)).toBe(true) 311 314 if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 315 + }) 316 + }) 317 + 318 + describe("repeatEff", () => { 319 + it("repeats effect n times and returns last result", async () => { 320 + let count = 0 321 + const eff = sync(() => ++count) 322 + const result = await runPromise(repeatEff(3)(eff)) 323 + expect(result).toBe(3) 324 + expect(count).toBe(3) 325 + }) 326 + 327 + it("repeats 0 times executes once", async () => { 328 + let count = 0 329 + const eff = sync(() => ++count) 330 + const result = await runPromise(repeatEff(0)(eff)) 331 + expect(result).toBe(1) 332 + expect(count).toBe(1) 333 + }) 334 + 335 + it("stops on first failure", async () => { 336 + let count = 0 337 + const eff = pipe( 338 + sync(() => ++count), 339 + flatMap((n) => (n === 2 ? fail("boom") : succeed(n))), 340 + ) 341 + const exit = await runPromiseExit(repeatEff(5)(eff)) 342 + expect(Exit.isFailure(exit)).toBe(true) 343 + expect(count).toBe(2) 344 + }) 345 + }) 346 + 347 + describe("interruptFiber", () => { 348 + it("interrupts a fiber via effect combinator", async () => { 349 + const program = pipe( 350 + fork( 351 + pipe( 352 + sleep(1000), 353 + mapEff(() => "done"), 354 + ), 355 + ), 356 + flatMap((fiber) => 357 + pipe( 358 + interruptFiber(fiber), 359 + mapEff(() => fiber), 360 + ), 361 + ), 362 + ) 363 + const fiber = await runPromise(program) 364 + const exit = await fiber.await() 365 + expect(Exit.isInterrupted(exit)).toBe(true) 312 366 }) 313 367 }) 314 368
+30
tests/exit.test.ts
··· 4 4 Exit, 5 5 failure, 6 6 interrupted, 7 + matchExit, 7 8 pipe, 8 9 success, 9 10 } from "../src/index" ··· 92 93 ), 93 94 ) 94 95 expect(exit).toEqual(success("42")) 96 + }) 97 + }) 98 + 99 + describe("matchExit", () => { 100 + it("calls onSuccess for Success", () => { 101 + const format = matchExit<number, string, string>( 102 + (v) => `value: ${v}`, 103 + (e) => `error: ${e}`, 104 + (by) => `interrupted by: ${by}`, 105 + ) 106 + expect(format(success(42))).toBe("value: 42") 107 + }) 108 + 109 + it("calls onFailure for Failure", () => { 110 + const format = matchExit<number, string, string>( 111 + (v) => `value: ${v}`, 112 + (e) => `error: ${e}`, 113 + (by) => `interrupted by: ${by}`, 114 + ) 115 + expect(format(failure("boom"))).toBe("error: boom") 116 + }) 117 + 118 + it("calls onInterrupted for Interrupted", () => { 119 + const format = matchExit<number, string, string>( 120 + (v) => `value: ${v}`, 121 + (e) => `error: ${e}`, 122 + (by) => `interrupted by: ${by}`, 123 + ) 124 + expect(format(interrupted("user"))).toBe("interrupted by: user") 95 125 }) 96 126 }) 97 127
+40
tests/option.test.ts
··· 10 10 pipe, 11 11 sequenceOption, 12 12 some, 13 + toNullable, 13 14 traverseOption, 14 15 } from "../src/index" 15 16 ··· 132 133 orElseOption(() => none), 133 134 ) 134 135 expect(result).toEqual(none) 136 + }) 137 + }) 138 + 139 + describe("toNullable", () => { 140 + it("returns value for Some", () => { 141 + expect(toNullable(some(42))).toBe(42) 142 + }) 143 + 144 + it("returns null for None", () => { 145 + expect(toNullable(none)).toBeNull() 146 + }) 147 + 148 + it("preserves falsy values in Some", () => { 149 + expect(toNullable(some(0))).toBe(0) 150 + expect(toNullable(some(""))).toBe("") 151 + expect(toNullable(some(false))).toBe(false) 152 + }) 153 + }) 154 + 155 + describe("flatMapOption (edge cases)", () => { 156 + it("returns None when function returns None", () => { 157 + const result = pipe( 158 + some(42), 159 + flatMapOption(() => none as Option<number>), 160 + ) 161 + expect(result).toEqual(none) 162 + }) 163 + 164 + it("short-circuits on initial None", () => { 165 + let called = false 166 + const result = pipe( 167 + none as Option<number>, 168 + flatMapOption((x) => { 169 + called = true 170 + return some(x * 2) 171 + }), 172 + ) 173 + expect(result).toEqual(none) 174 + expect(called).toBe(false) 135 175 }) 136 176 }) 137 177
+61
tests/pattern-matching.test.ts
··· 2 2 import { 3 3 err, 4 4 match, 5 + matchLiteral, 6 + matchLiteralOr, 5 7 matchOn, 6 8 matchOnOr, 7 9 matchOption, ··· 121 123 ) 122 124 expect(format(some(42))).toBe("found: 42") 123 125 expect(format(none)).toBe("empty") 126 + }) 127 + }) 128 + 129 + describe("matchOr (matched case)", () => { 130 + it("returns handler result when case matches", () => { 131 + const describe = matchOr<Shape, string>("unknown") 132 + const result = describe({ _tag: "Circle", radius: 5 })({ 133 + Circle: ({ radius }) => `circle r=${radius}`, 134 + }) 135 + expect(result).toBe("circle r=5") 136 + }) 137 + }) 138 + 139 + describe("matchLiteral", () => { 140 + it("matches string literals exhaustively", () => { 141 + const color = "red" as "red" | "green" | "blue" 142 + const result = matchLiteral(color)({ 143 + red: "#ff0000", 144 + green: "#00ff00", 145 + blue: "#0000ff", 146 + }) 147 + expect(result).toBe("#ff0000") 148 + }) 149 + 150 + it("matches number literals", () => { 151 + const code = 200 as 200 | 404 | 500 152 + const result = matchLiteral(code)({ 153 + 200: "ok", 154 + 404: "not found", 155 + 500: "server error", 156 + }) 157 + expect(result).toBe("ok") 158 + }) 159 + 160 + it("matches boolean literals", () => { 161 + const flag = true as boolean 162 + const result = matchLiteral(flag)({ 163 + true: "yes", 164 + false: "no", 165 + }) 166 + expect(result).toBe("yes") 167 + }) 168 + }) 169 + 170 + describe("matchLiteralOr", () => { 171 + it("returns matched case when present", () => { 172 + const dir = "north" as "north" | "south" | "east" | "west" 173 + const result = matchLiteralOr(dir)("unknown")({ 174 + north: "up", 175 + }) 176 + expect(result).toBe("up") 177 + }) 178 + 179 + it("returns default when case is not in partial record", () => { 180 + const dir = "east" as "north" | "south" | "east" | "west" 181 + const result = matchLiteralOr(dir)("unknown")({ 182 + north: "up", 183 + }) 184 + expect(result).toBe("unknown") 124 185 }) 125 186 }) 126 187 })
+47
tests/result.test.ts
··· 4 4 chainResult, 5 5 err, 6 6 fromNullableResult, 7 + isErr, 8 + isOk, 9 + mapErr, 7 10 mapResult, 8 11 ok, 9 12 orElseResult, ··· 179 182 it("returns Err for undefined", () => { 180 183 const result = fromNullableResult(() => "was undefined")(undefined) 181 184 expect(result).toEqual(err("was undefined")) 185 + }) 186 + }) 187 + 188 + describe("isOk / isErr", () => { 189 + it("isOk returns true for Ok", () => { 190 + expect(isOk(ok(42))).toBe(true) 191 + }) 192 + 193 + it("isOk returns false for Err", () => { 194 + expect(isOk(err("e"))).toBe(false) 195 + }) 196 + 197 + it("isErr returns true for Err", () => { 198 + expect(isErr(err("e"))).toBe(true) 199 + }) 200 + 201 + it("isErr returns false for Ok", () => { 202 + expect(isErr(ok(42))).toBe(false) 203 + }) 204 + }) 205 + 206 + describe("mapErr", () => { 207 + it("transforms Err value", () => { 208 + const result = pipe( 209 + err("oops"), 210 + mapErr((e) => e.toUpperCase()), 211 + ) 212 + expect(result).toEqual(err("OOPS")) 213 + }) 214 + 215 + it("passes through Ok unchanged", () => { 216 + const result = pipe( 217 + ok(42), 218 + mapErr((e: string) => e.toUpperCase()), 219 + ) 220 + expect(result).toEqual(ok(42)) 221 + }) 222 + 223 + it("transforms error type", () => { 224 + const result = pipe( 225 + err("raw"), 226 + mapErr((e) => ({ _tag: "Wrapped" as const, message: e })), 227 + ) 228 + expect(result).toEqual(err({ _tag: "Wrapped", message: "raw" })) 182 229 }) 183 230 }) 184 231
+71
tests/typeclasses.test.ts
··· 227 227 }) 228 228 }) 229 229 230 + describe("liftA2Option (both-None)", () => { 231 + it("returns None when both are None", () => { 232 + const add = (a: number, b: number) => a + b 233 + const addOptions = liftA2Option(add) 234 + expect(addOptions(none, none)._tag).toBe("None") 235 + }) 236 + }) 237 + 230 238 describe("Monad laws (Result)", () => { 231 239 it("left identity: flatMap(f)(of(a)) === f(a)", () => { 232 240 const f = (x: number) => ok(x * 2) ··· 244 252 const result = pipe(m, resultInstances.flatMap(resultInstances.of)) 245 253 246 254 expect(result).toEqual(m) 255 + }) 256 + }) 257 + 258 + describe("Functor laws (Option)", () => { 259 + it("identity: map(id) === id", () => { 260 + const id = <T>(x: T) => x 261 + const original = some(42) 262 + expect(pipe(original, optionInstances.map(id))).toEqual(original) 263 + }) 264 + 265 + it("identity on None: map(id) === id", () => { 266 + const id = <T>(x: T) => x 267 + expect(pipe(none, optionInstances.map(id))).toEqual(none) 268 + }) 269 + 270 + it("composition: map(f . g) === map(f) . map(g)", () => { 271 + const f = (x: number) => x * 2 272 + const g = (x: number) => x + 1 273 + const fg = (x: number) => f(g(x)) 274 + 275 + const original = some(5) 276 + const left = pipe(original, optionInstances.map(fg)) 277 + const right = pipe( 278 + original, 279 + optionInstances.map(g), 280 + optionInstances.map(f), 281 + ) 282 + 283 + expect(left).toEqual(right) 284 + }) 285 + }) 286 + 287 + describe("Monad laws (Option)", () => { 288 + it("left identity: flatMap(f)(of(a)) === f(a)", () => { 289 + const f = (x: number) => some(x * 2) 290 + const a = 5 291 + 292 + const left = pipe(optionInstances.of(a), optionInstances.flatMap(f)) 293 + const right = f(a) 294 + 295 + expect(left).toEqual(right) 296 + }) 297 + 298 + it("right identity: flatMap(of)(m) === m", () => { 299 + const m = some(42) 300 + 301 + const result = pipe(m, optionInstances.flatMap(optionInstances.of)) 302 + 303 + expect(result).toEqual(m) 304 + }) 305 + 306 + it("associativity: flatMap(g)(flatMap(f)(m)) === flatMap(x => flatMap(g)(f(x)))(m)", () => { 307 + const f = (x: number) => some(x * 2) 308 + const g = (x: number) => some(x + 1) 309 + 310 + const m = some(5) 311 + const left = pipe(m, optionInstances.flatMap(f), optionInstances.flatMap(g)) 312 + const right = pipe( 313 + m, 314 + optionInstances.flatMap((x) => pipe(f(x), optionInstances.flatMap(g))), 315 + ) 316 + 317 + expect(left).toEqual(right) 247 318 }) 248 319 }) 249 320 })