···11+# Changelog
22+33+All notable changes to this project will be documented in this file.
44+55+## [0.1.0-alpha.7] - 2026-02-07
66+77+### Added
88+99+- **flatMap error/requirement widening** — `flatMap` now automatically unions error and requirement types across composed effects, removing the need for manual type annotations
1010+- **matchOn / matchOnOr** — Pattern matching on custom discriminant fields (not just `_tag`), useful for third-party types or domain events
1111+- **zip / zipWith / zip3** — Typed parallel effect composition returning tuples with automatic error union
1212+- **ensure** — Predicate-based failure combinator for guarding success values in pipelines
1313+- **tapErr** — Error observation side effect (symmetric with `tapEff`), error still propagates
1414+- **traverseResult / sequenceResult** — Apply a fallible function to each element and collect results, short-circuiting on first error
1515+- **traverseOption / sequenceOption** — Apply an Option-returning function to each element, short-circuiting on first None
1616+- **orElseResult / orElseOption** — Recover from errors or None via a fallback function
1717+- **fromNullableResult** — Convert nullable values to Result with a custom error
1818+- **validate2 / validate3 / validate4** — Ergonomic combinators for combining 2-4 validations with error accumulation
1919+- **bracket** — Guaranteed resource cleanup pattern (acquire/use/release) where release always runs
2020+2121+### Changed
2222+2323+- `flatMap` signature changed from `<A, B, E, R>` to split generics `<A, B, E2, R2>` + `<E, R>` for automatic widening
2424+2525+## [0.1.0-alpha.6] - 2026-02-07
2626+2727+### Added
2828+2929+- Beaver's Big System story trilogy teaching Branded Types, Typestate, ADTs, and Tracked Arrays
3030+- Forest Election story trilogy teaching Validation, Result, and Effects
3131+- Concept documentation for typestate, effects, fibers, DI, and testing
3232+- Pattern matching examples and O(n) array documentation
3333+3434+## [0.1.0-alpha.5] - 2026-02-06
3535+3636+### Added
3737+3838+- Tutorial guide chapters 1-10
3939+- Concept deep-dive articles
4040+- Side-by-side examples (http-client, workflow-engine, task-queue)
4141+4242+## [0.1.0-alpha.4] - 2026-02-05
4343+4444+### Added
4545+4646+- Multi-file architecture (prelude, effect, data modules)
4747+- Validation type with error accumulation
4848+- Entity helpers and type guards
4949+5050+## [0.1.0-alpha.3] - 2026-02-04
5151+5252+### Added
5353+5454+- Effect combinators (race, all, timeout, retry, repeat)
5555+- Fiber-based concurrency with cancellation
5656+5757+## [0.1.0-alpha.2] - 2026-02-03
5858+5959+### Added
6060+6161+- Effect system with typed errors and dependency injection
6262+- Fork/join concurrency model
6363+6464+## [0.1.0-alpha.1] - 2026-02-02
6565+6666+### Added
6767+6868+- Initial release with Result, Option, branded types, refinements, pattern matching, tracked arrays, units, and typestate
+12-2
docs/guides/README.md
···6969| Story | Concept | Description |
7070|--------------------------------------------------------------------|--------------------------|------------------------------------------------|
7171| [The Forest Election](./stories/forest-election/) | Validation, Result, Effects | Building a fair election system for the forest |
7272+| [Beaver's Big System](./stories/beavers-big-system/) | Branded Types, Typestate, ADTs, Tracked Arrays | Building maintainable maintenance software |
72737374---
7475···9091```typescript
9192// Core types and functions
9293import {
9393- type Result, ok, err, matchResult,
9494+ type Result, ok, err, matchResult, chainResult, mapResult,
9595+ traverseResult, sequenceResult, orElseResult, fromNullableResult,
9496 type Option, some, none, matchOption,
9797+ traverseOption, sequenceOption, orElseOption,
9598 type Eff, succeed, fail, flatMap, pipe,
9699 type Branded, brand,
97100} from "purus-ts"
···99102// Effect runners
100103import { runPromise, runPromiseExit } from "purus-ts"
101104102102-// Combinators
105105+// Effect combinators
106106+import { zip, zipWith, zip3, ensure, tapErr, bracket } from "purus-ts"
103107import { timeout, retry, race, all } from "purus-ts"
108108+109109+// Validation
110110+import { valid, invalid, validate2, validate3, validate4 } from "purus-ts"
111111+112112+// Pattern matching (custom discriminant)
113113+import { match, matchOn, matchOnOr } from "purus-ts"
104114```
+55
docs/guides/concepts/01-errors-as-values.md
···380380381381---
382382383383+## Working with Collections
384384+385385+### traverseResult — Apply a Fallible Function to Each Element
386386+387387+```typescript
388388+import { traverseResult, ok, err, pipe } from "purus-ts"
389389+390390+const parseId = (s: string): Result<number, string> => {
391391+ const n = Number(s)
392392+ return Number.isNaN(n) ? err(`Invalid: ${s}`) : ok(n)
393393+}
394394+395395+pipe(["1", "2", "3"], traverseResult(parseId)) // Ok([1, 2, 3])
396396+pipe(["1", "x", "3"], traverseResult(parseId)) // Err("Invalid: x")
397397+```
398398+399399+Short-circuits on the first error — no wasted work.
400400+401401+### sequenceResult — Collect an Array of Results
402402+403403+```typescript
404404+import { sequenceResult, ok, err } from "purus-ts"
405405+406406+sequenceResult([ok(1), ok(2), ok(3)]) // Ok([1, 2, 3])
407407+sequenceResult([ok(1), err("b")]) // Err("b")
408408+```
409409+410410+### orElseResult — Recover from Errors
411411+412412+```typescript
413413+import { orElseResult, err, ok, pipe } from "purus-ts"
414414+415415+pipe(
416416+ err("cache miss"),
417417+ orElseResult(() => ok(42))
418418+)
419419+// Ok(42) — recovered via fallback
420420+```
421421+422422+### fromNullableResult — Convert Nullable to Result
423423+424424+```typescript
425425+import { fromNullableResult, pipe } from "purus-ts"
426426+427427+const findUser = (id: string): User | null => ...
428428+429429+pipe(
430430+ findUser("123"),
431431+ fromNullableResult(() => ({ _tag: "NotFound" as const, id: "123" }))
432432+)
433433+// Ok(user) or Err({ _tag: "NotFound", id: "123" })
434434+```
435435+436436+---
437437+383438## Key Takeaways
3844393854401. **Exceptions break types** - `catch (e: unknown)` loses all error information
···44 async,
55 type Eff,
66 type Fiber,
77+ fail,
78 flatMap,
89 foldEff,
910 fork,
···2324 f(a)
2425 return succeed(a) as Eff<A, E, R>
2526 }),
2727+ )
2828+2929+// === Ensure - predicate-based failure ===
3030+3131+/**
3232+ * Fail with a custom error if the predicate returns false.
3333+ * The value passes through unchanged when the predicate is true.
3434+ *
3535+ * @example
3636+ * ```typescript
3737+ * pipe(
3838+ * loadMixtape(id),
3939+ * ensure(
4040+ * (m) => m.userId === userId,
4141+ * () => ({ _tag: "Forbidden" as const })
4242+ * ),
4343+ * )
4444+ * ```
4545+ */
4646+export const ensure = <A, E2>(
4747+ predicate: (a: A) => boolean,
4848+ onFalse: (a: A) => E2,
4949+) =>
5050+ <E, R>(eff: Eff<A, E, R>): Eff<A, E | E2, R> =>
5151+ pipe(
5252+ eff,
5353+ flatMap((a) =>
5454+ predicate(a)
5555+ ? (succeed(a) as Eff<A, E | E2, R>)
5656+ : (fail(onFalse(a)) as Eff<A, E | E2, R>),
5757+ ),
5858+ ) as Eff<A, E | E2, R>
5959+6060+// === TapErr - side effect on error ===
6161+6262+/**
6363+ * Observe an error without consuming it. The error still propagates.
6464+ * Symmetric with tapEff which observes success values.
6565+ *
6666+ * @example
6767+ * ```typescript
6868+ * pipe(
6969+ * loadUser(id),
7070+ * tapErr((e) => logger.error("Load failed", { error: e })),
7171+ * tapEff((u) => logger.debug("Loaded", { id: u.id })),
7272+ * )
7373+ * ```
7474+ */
7575+export const tapErr = <E>(f: (e: E) => void) =>
7676+ <A, R>(eff: Eff<A, E, R>): Eff<A, E, R> =>
7777+ pipe(
7878+ eff,
7979+ foldEff(
8080+ (e: E) => {
8181+ f(e)
8282+ return fail(e) as Eff<A, E, R>
8383+ },
8484+ (a: A) => succeed(a) as Eff<A, E, R>,
8585+ ),
2686 )
27872888// === Join ===
···168228 ),
169229 )
170230231231+// === Zip - combine effects into tuples ===
232232+233233+/**
234234+ * Combine two effects into a tuple, running them concurrently.
235235+ * Error types are automatically widened to the union.
236236+ *
237237+ * @example
238238+ * ```typescript
239239+ * const result = await runPromise(zip(fetchUser(id), fetchPosts(id)))
240240+ * // readonly [User, Post[]]
241241+ * ```
242242+ */
243243+export const zip = <A, B, E1, E2, R1, R2>(
244244+ eff1: Eff<A, E1, R1>,
245245+ eff2: Eff<B, E2, R2>,
246246+): Eff<readonly [A, B], E1 | E2, R1 | R2> =>
247247+ pipe(
248248+ fork(eff1 as Eff<A, E1 | E2, R1 | R2>) as unknown as Eff<
249249+ Fiber<A, E1 | E2>,
250250+ E1 | E2,
251251+ R1 | R2
252252+ >,
253253+ flatMap((fiber1) =>
254254+ pipe(
255255+ eff2 as Eff<B, E1 | E2, R1 | R2>,
256256+ flatMap((b) =>
257257+ pipe(
258258+ join(fiber1),
259259+ mapEff((a): readonly [A, B] => [a, b] as const),
260260+ ),
261261+ ),
262262+ ),
263263+ ),
264264+ ) as Eff<readonly [A, B], E1 | E2, R1 | R2>
265265+266266+/**
267267+ * Combine two effects with a mapping function, running them concurrently.
268268+ *
269269+ * @example
270270+ * ```typescript
271271+ * const total = zipWith(getPrice(id), getTax(id), (price, tax) => price + tax)
272272+ * ```
273273+ */
274274+export const zipWith = <A, B, C, E1, E2, R1, R2>(
275275+ eff1: Eff<A, E1, R1>,
276276+ eff2: Eff<B, E2, R2>,
277277+ f: (a: A, b: B) => C,
278278+): Eff<C, E1 | E2, R1 | R2> =>
279279+ pipe(
280280+ zip(eff1, eff2),
281281+ mapEff(([a, b]) => f(a, b)),
282282+ )
283283+284284+/**
285285+ * Combine three effects into a tuple, running them concurrently.
286286+ *
287287+ * @example
288288+ * ```typescript
289289+ * const [users, posts, comments] = await runPromise(
290290+ * zip3(fetchUsers(), fetchPosts(), fetchComments())
291291+ * )
292292+ * ```
293293+ */
294294+export const zip3 = <A, B, C, E1, E2, E3, R1, R2, R3>(
295295+ eff1: Eff<A, E1, R1>,
296296+ eff2: Eff<B, E2, R2>,
297297+ eff3: Eff<C, E3, R3>,
298298+): Eff<readonly [A, B, C], E1 | E2 | E3, R1 | R2 | R3> =>
299299+ pipe(
300300+ zip(eff1, eff2) as Eff<readonly [A, B], E1 | E2 | E3, R1 | R2 | R3>,
301301+ flatMap(([a, b]) =>
302302+ pipe(
303303+ eff3 as Eff<C, E1 | E2 | E3, R1 | R2 | R3>,
304304+ mapEff((c): readonly [A, B, C] => [a, b, c] as const),
305305+ ),
306306+ ),
307307+ ) as Eff<readonly [A, B, C], E1 | E2 | E3, R1 | R2 | R3>
308308+171309// === Traverse / Sequence ===
172310173311/**
···305443 flatMap((a) => go(times - 1, a)),
306444 )
307445 }
446446+447447+// === Bracket - guaranteed resource cleanup ===
448448+449449+/**
450450+ * Acquire a resource, use it, and guarantee cleanup.
451451+ * Release runs whether use succeeds or fails (like try/finally).
452452+ *
453453+ * @example
454454+ * ```typescript
455455+ * const withTransaction = <A, E>(
456456+ * operation: (tx: Transaction) => Eff<A, E, unknown>
457457+ * ): Eff<A, E, unknown> =>
458458+ * bracket(
459459+ * sync(() => db.transaction()),
460460+ * (tx) => sync(() => tx.close()),
461461+ * operation,
462462+ * )
463463+ * ```
464464+ */
465465+export const bracket = <A, B, E, R>(
466466+ acquire: Eff<A, E, R>,
467467+ release: (a: A) => Eff<void, never, R>,
468468+ use: (a: A) => Eff<B, E, R>,
469469+): Eff<B, E, R> =>
470470+ pipe(
471471+ acquire,
472472+ flatMap((resource) =>
473473+ pipe(
474474+ use(resource),
475475+ foldEff(
476476+ (e) =>
477477+ pipe(
478478+ release(resource) as Eff<void, E, R>,
479479+ flatMap(() => fail(e) as Eff<B, E, R>),
480480+ ),
481481+ (b) =>
482482+ pipe(
483483+ release(resource) as Eff<void, E, R>,
484484+ mapEff(() => b),
485485+ ),
486486+ ),
487487+ ),
488488+ ),
489489+ )
+7-7
src/effect/eff.ts
···199199 * ```
200200 */
201201export const flatMap =
202202- <A, B, E, R>(f: (a: A) => Eff<B, E, R>) =>
203203- (effect: Eff<A, E, R>): Eff<B, E, R> => ({
202202+ <A, B, E2, R2>(f: (a: A) => Eff<B, E2, R2>) =>
203203+ <E, R>(effect: Eff<A, E, R>): Eff<B, E | E2, R | R2> => ({
204204 _tag: "FlatMap",
205205- effect: effect as Eff<unknown, E, R>,
206206- f: f as (a: unknown) => Eff<B, E, R>,
205205+ effect: effect as Eff<unknown, E | E2, R | R2>,
206206+ f: f as (a: unknown) => Eff<B, E | E2, R | R2>,
207207 })
208208209209/**
···220220 * ```
221221 */
222222export const mapEff =
223223- <A, B, E, R>(f: (a: A) => B) =>
224224- (effect: Eff<A, E, R>): Eff<B, E, R> =>
225225- flatMap((a: A) => succeed(f(a)) as Eff<B, E, R>)(effect)
223223+ <A, B>(f: (a: A) => B) =>
224224+ <E, R>(effect: Eff<A, E, R>): Eff<B, E, R> =>
225225+ flatMap((a: A) => succeed(f(a)))(effect) as Eff<B, E, R>
226226227227/**
228228 * Handle both success and failure cases of an effect.
+55
src/prelude/match.ts
···8080 : defaultValue
81818282/**
8383+ * Pattern matching with a custom discriminant field.
8484+ *
8585+ * Like match() but works with any string field as the discriminant,
8686+ * not just _tag. Useful for unions discriminated by `type`, `kind`, etc.
8787+ *
8888+ * @example
8989+ * ```typescript
9090+ * type Event =
9191+ * | { type: "click"; x: number; y: number }
9292+ * | { type: "keypress"; key: string }
9393+ *
9494+ * const describe = (e: Event): string =>
9595+ * matchOn("type")(e)({
9696+ * click: ({ x, y }) => `Click at ${x},${y}`,
9797+ * keypress: ({ key }) => `Key: ${key}`,
9898+ * })
9999+ * ```
100100+ */
101101+export const matchOn =
102102+ <D extends string>(discriminant: D) =>
103103+ <T extends Record<D, string>>(value: T) =>
104104+ <R>(cases: { [K in T[D] & string]: (v: Extract<T, Record<D, K>>) => R }): R =>
105105+ (cases as unknown as Record<string, (v: T) => R>)[value[discriminant]]!(value)
106106+107107+/**
108108+ * Pattern matching with a custom discriminant and a default case.
109109+ *
110110+ * Like matchOn() but unhandled cases return the default value.
111111+ *
112112+ * @example
113113+ * ```typescript
114114+ * const isSpecial = (e: Event): boolean =>
115115+ * matchOnOr("type")(false)(e)({
116116+ * click: () => true,
117117+ * })
118118+ * ```
119119+ */
120120+export const matchOnOr =
121121+ <D extends string>(discriminant: D) =>
122122+ <R>(defaultValue: R) =>
123123+ <T extends Record<D, string>>(value: T) =>
124124+ (
125125+ cases: Partial<{
126126+ [K in T[D] & string]: (v: Extract<T, Record<D, K>>) => R
127127+ }>,
128128+ ): R =>
129129+ (cases as unknown as Record<string, ((v: T) => R) | undefined>)[
130130+ value[discriminant]
131131+ ] !== undefined
132132+ ? (cases as unknown as Record<string, (v: T) => R>)[
133133+ value[discriminant]
134134+ ]!(value)
135135+ : defaultValue
136136+137137+/**
83138 * Guard-based pattern matching with predicates.
84139 *
85140 * Unlike match() which dispatches on _tag, when() uses predicate functions.
+54
src/prelude/option.ts
···175175 */
176176export const toNullable = <T>(option: Option<T>): T | null =>
177177 option._tag === "Some" ? option.value : null
178178+179179+/**
180180+ * Apply an Option-returning function to each element, collecting values.
181181+ * Short-circuits on the first None.
182182+ *
183183+ * @example
184184+ * ```typescript
185185+ * const lookupAll = traverseOption(findUser)(["id1", "id2"])
186186+ * // Some([user1, user2]) or None
187187+ * ```
188188+ */
189189+export const traverseOption =
190190+ <A, B>(f: (a: A) => Option<B>) =>
191191+ (as: readonly A[]): Option<readonly B[]> => {
192192+ const results: B[] = []
193193+ for (const a of as) {
194194+ const o = f(a)
195195+ if (o._tag === "None") return none
196196+ results.push(o.value)
197197+ }
198198+ return some(results)
199199+ }
200200+201201+/**
202202+ * Collapse an array of Options into an Option of an array.
203203+ * Returns None if any element is None.
204204+ *
205205+ * @example
206206+ * ```typescript
207207+ * sequenceOption([some(1), some(2)]) // Some([1, 2])
208208+ * sequenceOption([some(1), none]) // None
209209+ * ```
210210+ */
211211+export const sequenceOption = <A>(
212212+ options: readonly Option<A>[],
213213+): Option<readonly A[]> =>
214214+ traverseOption<Option<A>, A>((o) => o)(options)
215215+216216+/**
217217+ * Try an alternative when an Option is None.
218218+ * Some passes through unchanged; None invokes the fallback.
219219+ *
220220+ * @example
221221+ * ```typescript
222222+ * pipe(
223223+ * findInCache(id),
224224+ * orElseOption(() => findInDb(id)),
225225+ * )
226226+ * ```
227227+ */
228228+export const orElseOption =
229229+ <A>(f: () => Option<A>) =>
230230+ (option: Option<A>): Option<A> =>
231231+ option._tag === "Some" ? option : f()
+69
src/prelude/result.ts
···239239 return err(e)
240240 }
241241}
242242+243243+/**
244244+ * Apply a Result-returning function to each element, collecting successes.
245245+ * Short-circuits on the first Err.
246246+ *
247247+ * @example
248248+ * ```typescript
249249+ * const parseAll = traverseResult(parseInt)(["1", "2", "3"])
250250+ * // Ok([1, 2, 3])
251251+ * ```
252252+ */
253253+export const traverseResult =
254254+ <A, B, E>(f: (a: A) => Result<B, E>) =>
255255+ (as: readonly A[]): Result<readonly B[], E> => {
256256+ const results: B[] = []
257257+ for (const a of as) {
258258+ const r = f(a)
259259+ if (r._tag === "Err") return r as unknown as Result<readonly B[], E>
260260+ results.push(r.value)
261261+ }
262262+ return ok(results)
263263+ }
264264+265265+/**
266266+ * Collapse an array of Results into a Result of an array.
267267+ * Short-circuits on the first Err.
268268+ *
269269+ * @example
270270+ * ```typescript
271271+ * sequenceResult([ok(1), ok(2), ok(3)]) // Ok([1, 2, 3])
272272+ * sequenceResult([ok(1), err("x")]) // Err("x")
273273+ * ```
274274+ */
275275+export const sequenceResult = <A, E>(
276276+ results: readonly Result<A, E>[],
277277+): Result<readonly A[], E> =>
278278+ traverseResult<Result<A, E>, A, E>((r) => r)(results)
279279+280280+/**
281281+ * Try an alternative when a Result is Err.
282282+ * Ok passes through unchanged; Err invokes the fallback with the error.
283283+ *
284284+ * @example
285285+ * ```typescript
286286+ * pipe(
287287+ * parseFormat(raw),
288288+ * orElseResult((e) => parseFormat(raw.toUpperCase())),
289289+ * )
290290+ * ```
291291+ */
292292+export const orElseResult =
293293+ <A, E, F>(f: (e: E) => Result<A, F>) =>
294294+ (result: Result<A, E>): Result<A, F> =>
295295+ result._tag === "Ok" ? result : f(result.error)
296296+297297+/**
298298+ * Convert a nullable value to a Result with a custom error.
299299+ * Non-null values become Ok; null/undefined becomes Err.
300300+ *
301301+ * @example
302302+ * ```typescript
303303+ * const findUser = (id: string): Result<User, NotFoundError> =>
304304+ * fromNullableResult(() => notFoundError({ resource: "User", id }))(dbRow)
305305+ * ```
306306+ */
307307+export const fromNullableResult =
308308+ <E>(onNull: () => E) =>
309309+ <A>(value: A | null | undefined): Result<NonNullable<A>, E> =>
310310+ value != null ? ok(value as NonNullable<A>) : err(onNull())