···11+---
22+title: Recursion Over Loops
33+description: Why pure functional code avoids loops and how to replace them with expressions.
44+sidebar:
55+ order: 10
66+---
77+88+Pure functional code avoids loops. Not as a style preference — there's a structural reason. This article explains why and shows you what to use instead.
99+1010+---
1111+1212+## Expressions vs Statements
1313+1414+The core insight: **loops are statements, functional operations are expressions.**
1515+1616+A statement performs an action but produces no value. An expression always produces a value.
1717+1818+```typescript
1919+// Statement — produces nothing, must mutate something to be useful
2020+for (let i = 0; i < users.length; i++) {
2121+ results.push(transform(users[i]))
2222+}
2323+2424+// Expression — produces a value directly
2525+const results = users.map(transform)
2626+```
2727+2828+Statements force you into mutation. You need a mutable variable to collect results, a mutable counter to track position. Expressions compose — pipe the output of one into the input of another.
2929+3030+```typescript
3131+// Statements: 3 mutable variables, 4 steps
3232+let filtered: User[] = []
3333+for (const u of users) {
3434+ if (u.active) filtered.push(u)
3535+}
3636+let sorted = filtered.sort(byName)
3737+let names: string[] = []
3838+for (const u of sorted) {
3939+ names.push(u.name)
4040+}
4141+4242+// Expression: one pipeline, zero mutation
4343+const names = users
4444+ .filter(u => u.active)
4545+ .sort(byName)
4646+ .map(u => u.name)
4747+```
4848+4949+---
5050+5151+## forEach Is a Lie
5252+5353+`forEach` looks functional — it's a method on Array, it takes a callback. But look at its signature:
5454+5555+```typescript
5656+Array<T>.forEach(callback: (value: T, index: number) => void): void
5757+// ^^^^ ^^^^
5858+```
5959+6060+It returns `void`. Twice. The callback returns nothing. The method returns nothing. The _only_ way to do anything useful with `forEach` is through side effects — mutating variables, logging, pushing to external arrays.
6161+6262+Compare with `map`:
6363+6464+```typescript
6565+Array<T>.map(callback: (value: T, index: number) => U): U[]
6666+// ^ ^^
6767+```
6868+6969+`map` returns a value. The callback returns a value. Everything flows through return values, not mutation.
7070+7171+### The practical problems
7272+7373+`forEach` can't short-circuit. If you need to stop early, you're stuck — `break` doesn't work inside callbacks. You'd need `for...of` or `some`/`find`.
7474+7575+`forEach` can't accumulate. Need to build up a result? You must declare a mutable variable outside the loop, then mutate it inside.
7676+7777+```typescript
7878+// forEach forces mutation
7979+const seen = new Set<string>()
8080+items.forEach(item => {
8181+ seen.add(item.id) // mutating external state
8282+})
8383+8484+// reduce threads the accumulator
8585+const seen = items.reduce(
8686+ (acc, item) => new Set([...acc, item.id]),
8787+ new Set<string>()
8888+)
8989+```
9090+9191+---
9292+9393+## The Functional Toolkit
9494+9595+Four methods replace virtually all loops:
9696+9797+### map — transform each element
9898+9999+```typescript
100100+// forEach + mutation
101101+const names: string[] = []
102102+users.forEach(u => names.push(u.name))
103103+104104+// map
105105+const names = users.map(u => u.name)
106106+```
107107+108108+### filter — keep elements matching a predicate
109109+110110+```typescript
111111+// for loop + mutation
112112+const active: User[] = []
113113+for (const u of users) {
114114+ if (u.isActive) active.push(u)
115115+}
116116+117117+// filter
118118+const active = users.filter(u => u.isActive)
119119+```
120120+121121+### reduce — accumulate into any shape
122122+123123+```typescript
124124+// for loop + mutable counter
125125+let total = 0
126126+for (const item of cart) {
127127+ total += item.price * item.quantity
128128+}
129129+130130+// reduce
131131+const total = cart.reduce(
132132+ (sum, item) => sum + item.price * item.quantity, 0
133133+)
134134+```
135135+136136+### flatMap — transform and flatten
137137+138138+```typescript
139139+// forEach + mutation
140140+const allTags: string[] = []
141141+posts.forEach(p => p.tags.forEach(t => allTags.push(t)))
142142+143143+// flatMap
144144+const allTags = posts.flatMap(p => p.tags)
145145+```
146146+147147+### Display pattern: map + join
148148+149149+A common case is rendering a list to the console. Instead of `forEach` with `console.log` inside, use `map` + `join`:
150150+151151+```typescript
152152+// forEach — one console.log per item
153153+errors.forEach(e => console.log(` - ${formatError(e)}`))
154154+155155+// map + join — single expression
156156+console.log(errors.map(e => ` - ${formatError(e)}`).join("\n"))
157157+```
158158+159159+---
160160+161161+## When You Need State: reduce
162162+163163+`reduce` is the workhorse for stateful iteration. Instead of mutating variables, you thread an accumulator through each step.
164164+165165+Here's the ballot-counting example from the [Forest Election story](/stories/forest-election/01-the-ballot-box-problem/). The old version used `forEach` with a mutable `Set`:
166166+167167+```typescript
168168+// Mutable accumulation
169169+const alreadyVoted = new Set<string>()
170170+ballots.forEach(({ voter, candidate }, i) => {
171171+ const result = validateBallotFull(voter, candidate, alreadyVoted)
172172+ matchValidation(
173173+ (ballot) => { alreadyVoted.add(ballot.voter) },
174174+ (errors) => { /* ... */ }
175175+ )(result)
176176+})
177177+```
178178+179179+The functional version threads the set through `reduce`:
180180+181181+```typescript
182182+// Immutable accumulation
183183+ballots.reduce<ReadonlySet<string>>((voted, { voter, candidate }, i) => {
184184+ const result = validateBallotFull(voter, candidate, voted)
185185+ return matchValidation(
186186+ (ballot: Ballot) => {
187187+ console.log(`Ballot ${i + 1}: Valid - ${ballot.voter} → ${ballot.candidate}`)
188188+ return new Set([...voted, ballot.voter])
189189+ },
190190+ (errors: readonly BallotError[]) => {
191191+ console.log(`Ballot ${i + 1}: Invalid`)
192192+ console.log(errors.map(e => ` - ${formatError(e)}`).join("\n"))
193193+ return voted
194194+ }
195195+ )(result)
196196+}, new Set<string>())
197197+```
198198+199199+Each iteration returns the next state. No mutation. The types make the flow explicit — `ReadonlySet<string>` goes in and comes out.
200200+201201+---
202202+203203+## The go() Pattern
204204+205205+For control flow that doesn't fit `map`/`reduce` — recursion with variable step sizes, early termination on complex conditions — use a recursive inner function named `go`:
206206+207207+```typescript
208208+// From purus-ts: retry combinator
209209+export const retry =
210210+ (times: number) =>
211211+ <A, E, R>(eff: Eff<A, E, R>): Eff<A, E, R> => {
212212+ const go = (remaining: number): Eff<A, E, R> =>
213213+ remaining <= 0
214214+ ? eff
215215+ : pipe(
216216+ eff,
217217+ foldEff(
218218+ (_e: E) => go(remaining - 1),
219219+ (a: A) => succeed(a) as Eff<A, E, R>,
220220+ ),
221221+ )
222222+223223+ return go(times)
224224+ }
225225+```
226226+227227+The pattern is always the same:
228228+229229+1. Define `go` inside the outer function
230230+2. Base case returns a value
231231+3. Recursive case transforms and calls `go` again
232232+4. Call `go` with initial arguments
233233+234234+Here's `binarySearch` from purus — a classic `go` pattern:
235235+236236+```typescript
237237+export const binarySearch =
238238+ <T>(compare: (a: T, b: T) => number) =>
239239+ (target: T) =>
240240+ <P extends string>(xs: Arr<T, P | Sorted>): Option<number> => {
241241+ const go = (lo: number, hi: number): Option<number> =>
242242+ lo > hi
243243+ ? none
244244+ : pipe(Math.floor((lo + hi) / 2), (mid) =>
245245+ ((cmp) =>
246246+ cmp === 0
247247+ ? some(mid)
248248+ : cmp < 0
249249+ ? go(lo, mid - 1)
250250+ : go(mid + 1, hi))(compare(target, xs[mid]!)),
251251+ )
252252+ return go(0, xs.length - 1)
253253+ }
254254+```
255255+256256+A `while` loop version would need mutable `lo`, `hi`, and `result` variables. The recursive version has none.
257257+258258+---
259259+260260+## Head-Tail Decomposition
261261+262262+Processing a list by splitting it into the first element and the rest:
263263+264264+```typescript
265265+const sum = (xs: readonly number[]): number =>
266266+ xs.length === 0
267267+ ? 0
268268+ : xs[0]! + sum(xs.slice(1))
269269+```
270270+271271+This is elegant but has O(n) memory from `slice`. For practical code, prefer `reduce`:
272272+273273+```typescript
274274+const sum = (xs: readonly number[]): number =>
275275+ xs.reduce((acc, x) => acc + x, 0)
276276+```
277277+278278+Head-tail decomposition shines when the recursive structure mirrors the problem — tree traversals, nested data, or when you need to process elements in pairs.
279279+280280+---
281281+282282+## Cheatsheet
283283+284284+| Imperative Pattern | Functional Replacement |
285285+|---|---|
286286+| `arr.forEach(x => console.log(x))` | `console.log(arr.map(f).join("\n"))` |
287287+| `forEach` + push to external array | `map` or `flatMap` |
288288+| `forEach` + mutable counter/set | `reduce` |
289289+| `for...of` with accumulator | `reduce` |
290290+| `for (let i = 0; i < n; i++)` | `Array.from({length: n}, (_, i) => ...)` |
291291+| `while (condition)` | Recursive `go()` |
292292+| `Object.entries(o).forEach(...)` | `Object.entries(o).map(...).join(...)` |
293293+| `for...of` in test iteration | `test.each(cases)(...)` |
294294+295295+---
296296+297297+## The Performance Question
298298+299299+"Isn't `reduce` slower than `for`?"
300300+301301+In practice, no. V8 and other JS engines optimize built-in array methods heavily. The overhead of a function call per iteration is negligible compared to the actual work being done.
302302+303303+That said, in rare hot-path cases — tight inner loops processing millions of elements — a `for` loop may be appropriate. purus itself uses `for...of` inside `traverseResult` and `traverseOption` for exactly this reason. But notice: this is hidden behind a pure interface. The caller sees `traverseResult(f)(items)` — an expression that returns a value. The implementation detail stays internal.
304304+305305+The principle: **pure interfaces, pragmatic internals**. Your application code should be expressions all the way through. If a library function needs a `for` loop for performance, that's an implementation detail behind a functional API.
306306+307307+---
308308+309309+## Key Takeaways
310310+311311+- **Loops are statements** — they produce nothing and force mutation.
312312+- **`forEach` returns void** — it exists only for side effects.
313313+- **`map`, `filter`, `reduce`, `flatMap`** cover virtually all iteration needs.
314314+- **`reduce` replaces mutable accumulators** — thread state through the callback.
315315+- **The `go()` pattern** handles complex recursion — base case + recursive case.
316316+- **Display code** uses `map` + `join` instead of `forEach` + `console.log`.
317317+- **Performance** is not a concern in application code. Reserve `for` loops for library internals behind pure interfaces.
···11+---
22+title: HTTP Client
33+description: Fetching data with retry, timeout, and automatic cleanup.
44+sidebar:
55+ order: 1
66+---
77+88+Fetching data with retry and timeout.
99+1010+## The Problem
1111+1212+Every app fetches data from APIs. But production code needs retries, timeouts,
1313+and cleanup. In vanilla TypeScript, this means:
1414+1515+- Manual AbortController wiring
1616+- Remembering to clearTimeout in every code path
1717+- `catch (e: unknown)` with no idea what went wrong
1818+1919+## Run Both Versions
2020+2121+```bash
2222+bun run examples/http-client/without-purus.ts
2323+bun run examples/http-client/with-purus.ts
2424+```
2525+2626+## Without purus
2727+2828+ts` - same thing with:
2929+- `pipe(fetch, retry(3), timeout(5000))`
3030+- Cleanup function returned from async effect
3131+- Typed HttpError union
3232+- match() for exhaustive error handling
3333+3434+## Key Takeaways
3535+3636+- Typed errors tell you what can fail
3737+- Cleanup is automatic when you return it from async()
3838+- retry() and timeout() are composable functions
3939+4040+4141+## Full Code: without-purus.ts
4242+4343+```typescript
4444+/**
4545+ * HTTP Client - Vanilla TypeScript (Comparison)
4646+ * ==============================================
4747+ *
4848+ * This is the "before" version. Compare with with-purus.ts to see how purus
4949+ * improves resilient HTTP fetching.
5050+ *
5151+ * PROBLEMS THIS APPROACH HAS:
5252+ *
5353+ * 1. ERRORS ARE UNKNOWN - Every catch block has `e: unknown`. You have to
5454+ * instanceof check and message-sniff to figure out what happened.
5555+ * What if someone changes the error message? Your handling breaks.
5656+ *
5757+ * 2. MANUAL CLEANUP - AbortController and setTimeout need careful wiring.
5858+ * Notice how many places we call clearTimeout()? Miss one = memory leak.
5959+ * The early return on line 45 doesn't clear the timeout before throwing.
6060+ *
6161+ * 3. RETRY IS BAKED IN - The retry logic is tangled into fetchWithRetryAndTimeout.
6262+ * Want retry without timeout? Want timeout without retry? You need separate
6363+ * functions or complex option objects.
6464+ *
6565+ * Read through and count the clearTimeout() calls. Ask yourself: did we cover
6666+ * every path? (Hint: check line 45)
6767+ *
6868+ * Prerequisites: Promise understanding
6969+ * Next: Compare with with-purus.ts to see the improvement
7070+ */
7171+7272+type User = {
7373+ id: number
7474+ name: string
7575+ email: string
7676+}
7777+7878+// =============================================================================
7979+// SECTION 1: Fetch with Retry and Timeout
8080+// =============================================================================
8181+//
8282+// COMPLEXITY EXPLOSION:
8383+// What starts as a simple fetch() quickly becomes a maze of:
8484+// - Manual AbortController creation
8585+// - Multiple clearTimeout calls (easy to miss one!)
8686+// - Exponential backoff calculation inline
8787+// - Error message sniffing to detect timeout vs network errors
8888+//
8989+// GOTCHA #1: Line 66 - clearTimeout is in the catch block, but line 58
9090+// throws without clearing the timeout first. Memory leak potential!
9191+//
9292+// GOTCHA #2: lastError is `unknown` - we've lost all type information
9393+// about what actually went wrong.
9494+// =============================================================================
9595+9696+const fetchWithRetryAndTimeout = async (
9797+ url: string,
9898+ options: {
9999+ retries?: number
100100+ timeoutMs?: number
101101+ retryDelayMs?: number
102102+ } = {}
103103+): Promise<User> => {
104104+ const { retries = 3, timeoutMs = 5000, retryDelayMs = 1000 } = options
105105+ let lastError: unknown // <- We've lost all error type information here
106106+107107+ for (let attempt = 0; attempt <= retries; attempt++) {
108108+ const controller = new AbortController()
109109+ let timeoutId: ReturnType<typeof setTimeout> | undefined
110110+111111+ try {
112112+ // Wire up the timeout to abort the request
113113+ timeoutId = setTimeout(() => controller.abort(), timeoutMs)
114114+115115+ console.log(`[Attempt ${attempt + 1}/${retries + 1}] Fetching ${url}...`)
116116+117117+ const response = await fetch(url, { signal: controller.signal })
118118+119119+ clearTimeout(timeoutId) // Don't forget this!
120120+121121+ if (!response.ok) {
122122+ // GOTCHA: We cleared the timeout above, but if we restructure this code
123123+ // and move the throw before clearTimeout, we leak the timer.
124124+ // This kind of subtle ordering bug is why cleanup should be automatic.
125125+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
126126+ }
127127+128128+ const data = await response.json()
129129+ console.log(`[Success] Got response on attempt ${attempt + 1}`)
130130+ return data as User
131131+132132+ } catch (e: unknown) {
133133+ // Need to clean up here too - easy to miss in a refactor
134134+ if (timeoutId) clearTimeout(timeoutId)
135135+136136+ lastError = e // e is unknown - good luck figuring out what went wrong
137137+138138+ // MESSAGE SNIFFING: We detect timeout by checking error.name === "AbortError"
139139+ // This is fragile - browser implementations could differ
140140+ const isTimeout = e instanceof Error && e.name === "AbortError"
141141+ const message = e instanceof Error ? e.message : "Unknown error"
142142+143143+ console.log(`[Attempt ${attempt + 1}] Failed: ${isTimeout ? "Timeout" : message}`)
144144+145145+ if (attempt < retries) {
146146+ const delay = retryDelayMs * Math.pow(2, attempt)
147147+ console.log(`[Retry] Waiting ${delay}ms before retry...`)
148148+ await new Promise(resolve => setTimeout(resolve, delay))
149149+ }
150150+ }
151151+ }
152152+153153+ // Caller gets unknown - they have to do the same instanceof/message checking
154154+ throw lastError
155155+}
156156+157157+// =============================================================================
158158+// SECTION 2: Demo
159159+// =============================================================================
160160+//
161161+// NOTICE THE PATTERN:
162162+// Every test is wrapped in try/catch. Every catch block:
163163+// 1. Receives `e: unknown`
164164+// 2. Does instanceof Error check
165165+// 3. Extracts .message
166166+// 4. Has no compile-time guarantees about what errors are possible
167167+//
168168+// If fetchWithRetryAndTimeout starts throwing a new error type, these catch
169169+// blocks silently do the wrong thing. No compiler warning.
170170+// =============================================================================
171171+172172+const main = async () => {
173173+ console.log("=== HTTP Client (without purus) ===\n")
174174+175175+ try {
176176+ console.log("--- Test 1: Successful request ---")
177177+ const user = await fetchWithRetryAndTimeout(
178178+ "https://jsonplaceholder.typicode.com/users/1",
179179+ { retries: 2, timeoutMs: 5000 }
180180+ )
181181+ console.log(`Got user: ${user.name} (${user.email})\n`)
182182+183183+ } catch (e: unknown) {
184184+ // WHAT DID WE CATCH?
185185+ // - Network error? Timeout? 404? 500? JSON parse error?
186186+ // - TypeScript has no idea. We're on our own.
187187+ console.error("Failed:", e instanceof Error ? e.message : e)
188188+ }
189189+190190+ try {
191191+ console.log("--- Test 2: Short timeout ---")
192192+ const user = await fetchWithRetryAndTimeout(
193193+ "https://jsonplaceholder.typicode.com/users/1",
194194+ { retries: 1, timeoutMs: 1 }
195195+ )
196196+ console.log(`Got user: ${user.name}\n`)
197197+198198+ } catch (e: unknown) {
199199+ // We HOPE this is a timeout error, but TypeScript can't verify
200200+ console.log("Expected timeout:", e instanceof Error ? e.message : e)
201201+ console.log()
202202+ }
203203+204204+ try {
205205+ console.log("--- Test 3: 404 Not Found ---")
206206+ const user = await fetchWithRetryAndTimeout(
207207+ "https://jsonplaceholder.typicode.com/users/99999",
208208+ { retries: 0, timeoutMs: 5000 }
209209+ )
210210+ console.log(`Got user: ${user.name}\n`)
211211+212212+ } catch (e: unknown) {
213213+ // We HOPE this is a 404 error, but it could be anything
214214+ console.log("Expected 404:", e instanceof Error ? e.message : e)
215215+ console.log()
216216+ }
217217+218218+ console.log("=== Done ===")
219219+}
220220+221221+main()
222222+ .catch(console.error)
223223+ .finally(() => process.exit(0))
224224+```
225225+226226+227227+## Full Code: with-purus.ts
228228+229229+```typescript
230230+/**
231231+ * HTTP Client Example - Building Resilient Data Fetching
232232+ * ======================================================
233233+ *
234234+ * This example teaches three core purus concepts:
235235+ *
236236+ * 1. TYPED ERRORS - Know exactly what can fail (no `catch (e: unknown)`)
237237+ * The HttpError union has specific variants, so match() forces you to
238238+ * handle each case. Try commenting out a case - the compiler stops you.
239239+ *
240240+ * 2. COMPOSABLE OPERATIONS - pipe(), retry(), timeout() chain cleanly
241241+ * Each combinator is a pure function that transforms an Eff. You can
242242+ * add/remove/reorder them without restructuring your code.
243243+ *
244244+ * 3. AUTOMATIC CLEANUP - Return a cleanup function, never forget to clearTimeout
245245+ * The async() constructor takes a register function that returns a cleanup.
246246+ * When the effect is cancelled (timeout, interrupt), cleanup runs automatically.
247247+ *
248248+ * Compare with without-purus.ts to see how vanilla TypeScript handles the same
249249+ * problems (spoiler: lots of manual AbortController wiring and `e: unknown`).
250250+ *
251251+ * Prerequisites: Basic Promise understanding
252252+ * Next: workflow-engine for branded types and typestate
253253+ */
254254+255255+import {
256256+ type Eff,
257257+ Exit,
258258+ succeed,
259259+ fail,
260260+ async,
261261+ flatMap,
262262+ catchAll,
263263+ timeout,
264264+ retry,
265265+ pipe,
266266+ match,
267267+ runPromise,
268268+ runPromiseExit,
269269+} from "../../src/index"
270270+271271+// =============================================================================
272272+// SECTION 1: Error Types
273273+// =============================================================================
274274+//
275275+// WHY TYPED ERRORS MATTER:
276276+// In vanilla JS, errors are `unknown` in catch blocks. You end up with:
277277+// catch (e) { if (e instanceof Error && e.message.includes("timeout")) ... }
278278+//
279279+// With discriminated unions, each error variant is explicit in the type.
280280+// The match() function forces exhaustive handling - forget a case = compile error.
281281+//
282282+// PATTERN: Each error variant has a _tag (discriminant) and relevant data.
283283+// This is the standard discriminated union pattern in TypeScript.
284284+// =============================================================================
285285+286286+type HttpError =
287287+ | { readonly _tag: "NetworkError"; readonly message: string }
288288+ | { readonly _tag: "TimeoutError"; readonly ms: number }
289289+ | { readonly _tag: "NotFound"; readonly url: string }
290290+ | { readonly _tag: "ServerError"; readonly status: number }
291291+292292+// Smart constructors - these ensure consistent error creation
293293+const HttpError = {
294294+ network: (message: string): HttpError => ({ _tag: "NetworkError", message }),
295295+ timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }),
296296+ notFound: (url: string): HttpError => ({ _tag: "NotFound", url }),
297297+ serverError: (status: number): HttpError => ({ _tag: "ServerError", status }),
298298+}
299299+300300+type User = {
301301+ id: number
302302+ name: string
303303+ email: string
304304+}
305305+306306+// Type guard - validates the shape at runtime without casting
307307+const isUser = (data: unknown): data is User =>
308308+ typeof data === "object" &&
309309+ data !== null &&
310310+ "id" in data &&
311311+ "name" in data &&
312312+ "email" in data
313313+314314+// =============================================================================
315315+// SECTION 2: Fetch as an Effect
316316+// =============================================================================
317317+//
318318+// WHY USE async() INSTEAD OF fromPromise():
319319+// The async() constructor gives you control over cleanup. When an effect is
320320+// cancelled (via timeout, race, or manual interrupt), the cleanup function runs.
321321+//
322322+// HOW IT WORKS:
323323+// 1. async() takes a "register" function
324324+// 2. The register function receives a "resume" callback
325325+// 3. You start your async work and call resume(Exit.succeed(value)) or resume(Exit.fail(error))
326326+// 4. Return a cleanup function - it runs on cancellation
327327+//
328328+// GOTCHA: The AbortController abort() must happen in the cleanup function.
329329+// If you forget to return cleanup, the request keeps running even after timeout!
330330+// =============================================================================
331331+332332+const fetchUser = (url: string): Eff<User, HttpError, unknown> =>
333333+ async((resume) => {
334334+ // Create an AbortController - we'll abort it on cleanup
335335+ const controller = new AbortController()
336336+337337+ console.log(`[Fetch] ${url}`)
338338+339339+ fetch(url, { signal: controller.signal })
340340+ .then((response) =>
341341+ !response.ok
342342+ ? response.status === 404
343343+ ? resume(Exit.fail(HttpError.notFound(url)))
344344+ : resume(Exit.fail(HttpError.serverError(response.status)))
345345+ : response.json().then((data: unknown) =>
346346+ // Use type guard instead of casting - validates at runtime
347347+ isUser(data)
348348+ ? resume(Exit.succeed(data))
349349+ : resume(Exit.fail(HttpError.serverError(500))),
350350+ ),
351351+ )
352352+ .catch((err) => {
353353+ // GOTCHA: Don't resume on AbortError - the fiber is already cancelled
354354+ // Resuming after cancellation would cause undefined behavior
355355+ if (err.name === "AbortError") return
356356+ resume(Exit.fail(HttpError.network(err.message)))
357357+ })
358358+359359+ // THE KEY INSIGHT: This cleanup function runs automatically on timeout/interrupt
360360+ // No manual finally blocks, no forgetting to clearTimeout, no leaked requests
361361+ return () => {
362362+ console.log("[Cleanup] Aborting request")
363363+ controller.abort()
364364+ }
365365+ })
366366+367367+// =============================================================================
368368+// SECTION 3: Error Handling with match()
369369+// =============================================================================
370370+//
371371+// WHY match() OVER switch/if-else:
372372+// - match() is EXHAUSTIVE - add a new error variant, and all match() calls
373373+// that don't handle it become compile errors
374374+// - It's an expression, not a statement, so it always returns a value
375375+// - Each handler receives the correctly narrowed type (e.g., { _tag: "TimeoutError", ms })
376376+//
377377+// TRY THIS: Comment out one of the cases below. TypeScript will error.
378378+// =============================================================================
379379+380380+const handleError = (error: HttpError): string =>
381381+ match(error)({
382382+ NetworkError: ({ message }) => `Network failed: ${message}`,
383383+ TimeoutError: ({ ms }) => `Request timed out after ${ms}ms`,
384384+ NotFound: ({ url }) => `Resource not found: ${url}`,
385385+ ServerError: ({ status }) => `Server error: ${status}`,
386386+ })
387387+388388+// =============================================================================
389389+// SECTION 4: Demo
390390+// =============================================================================
391391+//
392392+// COMPOSABILITY IN ACTION:
393393+// Notice how each test builds up functionality using pipe():
394394+// - fetchUser() is the base effect
395395+// - retry(n) wraps it with retry logic
396396+// - timeout(ms) adds a timeout
397397+// - catchAll() recovers from errors
398398+//
399399+// Each combinator is independent - you can add, remove, or reorder them.
400400+// Compare this to the nested try/catch/finally in without-purus.ts.
401401+// =============================================================================
402402+403403+const main = async () => {
404404+ console.log("=== HTTP Client (with purus) ===\n")
405405+406406+ // ---------------------------------------------------------------------------
407407+ // Test 1: Successful request
408408+ // Shows: basic pipe with retry + timeout, happy path
409409+ // ---------------------------------------------------------------------------
410410+ console.log("--- Test 1: Successful request ---")
411411+412412+ const request1 = pipe(
413413+ fetchUser("https://jsonplaceholder.typicode.com/users/1"),
414414+ retry(2), // Retry up to 2 times on failure
415415+ timeout(5000), // Cancel if not done in 5s
416416+ flatMap((result) =>
417417+ // timeout() returns null on timeout, convert to typed error
418418+ result === null
419419+ ? fail(HttpError.timeout(5000))
420420+ : succeed(result)
421421+ )
422422+ )
423423+424424+ // Use runPromiseExit instead of try/catch - no type casting needed
425425+ const exit1 = await runPromiseExit(request1)
426426+ exit1._tag === "Success"
427427+ ? console.log(`Got user: ${exit1.value.name} (${exit1.value.email})\n`)
428428+ : exit1._tag === "Failure"
429429+ ? console.log(`Error: ${handleError(exit1.error)}\n`)
430430+ : console.log("Interrupted\n")
431431+432432+ // ---------------------------------------------------------------------------
433433+ // Test 2: Short timeout - will fail, but we recover
434434+ // Shows: catchAll() for error recovery, returns fallback value
435435+ // ---------------------------------------------------------------------------
436436+ console.log("--- Test 2: Short timeout ---")
437437+438438+ const request2 = pipe(
439439+ fetchUser("https://jsonplaceholder.typicode.com/users/1"),
440440+ timeout(1), // 1ms timeout = guaranteed timeout
441441+ flatMap((result) =>
442442+ result === null
443443+ ? fail(HttpError.timeout(1))
444444+ : succeed(result)
445445+ ),
446446+ // RECOVERY: catchAll transforms errors into success values
447447+ // The error is typed, so we know exactly what we're catching
448448+ catchAll((error) => (
449449+ console.log(`[Caught] ${handleError(error)}`),
450450+ succeed({ id: 0, name: "Timeout Fallback", email: "" })
451451+ ))
452452+ )
453453+454454+ const fallbackUser = await runPromise(request2)
455455+ console.log(`Result: ${fallbackUser.name}\n`)
456456+457457+ // ---------------------------------------------------------------------------
458458+ // Test 3: 404 error - recover with default
459459+ // Shows: server-side errors flow through typed error channel
460460+ // ---------------------------------------------------------------------------
461461+ console.log("--- Test 3: 404 Not Found ---")
462462+463463+ const request3 = pipe(
464464+ fetchUser("https://jsonplaceholder.typicode.com/users/99999"),
465465+ retry(0), // No retries for this test
466466+ catchAll((error) => (
467467+ console.log(`[Caught] ${handleError(error)}`),
468468+ succeed({ id: 0, name: "Default User", email: "" })
469469+ ))
470470+ )
471471+472472+ const user404 = await runPromise(request3)
473473+ console.log(`Result: ${user404.name}\n`)
474474+475475+ console.log("=== Done ===")
476476+}
477477+478478+main()
479479+ .catch(console.error)
480480+ .finally(() => process.exit(0))
481481+```
+58
docs-site/src/content/docs/examples/index.md
···11+---
22+title: Examples
33+description: Learn purus-ts through side-by-side comparisons with vanilla TypeScript.
44+---
55+66+Learn purus-ts through side-by-side comparisons with vanilla TypeScript.
77+88+Each example includes:
99+- **README.md** - The problem and how purus helps
1010+- **without-purus.ts** - Typical vanilla implementation
1111+- **with-purus.ts** - Same thing with purus
1212+1313+## Examples
1414+1515+### [http-client](/examples/http-client/)
1616+1717+Fetching data with retry and timeout.
1818+1919+**Key concepts:** `pipe`, `retry`, `timeout`, typed errors, automatic cleanup
2020+2121+```bash
2222+bun run examples/http-client/without-purus.ts
2323+bun run examples/http-client/with-purus.ts
2424+```
2525+2626+---
2727+2828+### [workflow-engine](/examples/workflow-engine/)
2929+3030+Order processing with IDs that can't be swapped and states that can't be skipped.
3131+3232+**Key concepts:** Branded types, typestate, `match`, Result
3333+3434+```bash
3535+bun run examples/workflow-engine/without-purus.ts
3636+bun run examples/workflow-engine/with-purus.ts
3737+```
3838+3939+---
4040+4141+### [task-queue](/examples/task-queue/)
4242+4343+Background job processing with real cancellation and dependency injection.
4444+4545+**Key concepts:** `fork`/`join`, `catchAll`, `provide` for DI
4646+4747+```bash
4848+bun run examples/task-queue/without-purus.ts
4949+bun run examples/task-queue/with-purus.ts
5050+```
5151+5252+---
5353+5454+## Why Side-by-Side?
5555+5656+Run both versions and compare. The vanilla code is realistic - not a strawman.
5757+Notice what changes: where the cleanup logic goes, how errors are handled,
5858+what the types tell you.
+532
docs-site/src/content/docs/examples/task-queue.md
···11+---
22+title: Task Queue
33+description: Background job processing with real cancellation and dependency injection.
44+sidebar:
55+ order: 3
66+---
77+88+Background job processing with real cancellation and easy testing.
99+1010+## The Problem
1111+1212+Background jobs need retries, timeouts, and error handling. In vanilla TypeScript:
1313+- Promise.race doesn't actually cancel the losing promise
1414+- Dependencies are hardcoded, making tests awkward
1515+- Error context gets lost after retries
1616+1717+## Run Both Versions
1818+1919+```bash
2020+bun run examples/task-queue/without-purus.ts
2121+bun run examples/task-queue/with-purus.ts
2222+```
2323+2424+## Without purus
2525+2626+race for timeout (but the job keeps running!)
2727+- Hardcoded logger - can't mock without a DI framework
2828+- Errors are `unknown` after retry exhaustion
2929+3030+## With purus
3131+3232+See `with-purus.ts`:
3333+- timeout() returns cleanup function - job actually stops
3434+- provide(env) injects dependencies - swap for tests
3535+- Typed errors preserved through the pipeline
3636+3737+## Key Takeaways
3838+3939+- Return a cleanup function from async() for real cancellation
4040+- provide() makes testing easy without frameworks
4141+- catchAll() preserves error context
4242+4343+4444+## Full Code: without-purus.ts
4545+4646+```typescript
4747+/**
4848+ * Task Queue - Vanilla TypeScript (Comparison)
4949+ * =============================================
5050+ *
5151+ * This is the "before" version. Compare with with-purus.ts to see how purus
5252+ * improves concurrency and testability.
5353+ *
5454+ * PROBLEMS THIS APPROACH HAS:
5555+ *
5656+ * 1. Promise.race DOESN'T CANCEL - It just ignores the loser
5757+ * See executeWithTimeout() on line 48. When timeout wins, the job
5858+ * KEEPS RUNNING in the background. We're not cancelling, just ignoring.
5959+ * This wastes resources and can cause side effects after "timeout".
6060+ *
6161+ * 2. HARDCODED LOGGER - Awkward to mock in tests
6262+ * The logger is a module-level constant (line 21-24).
6363+ * To test silently, you'd need jest.mock() or process.env checks.
6464+ * Compare with-purus.ts where you just swap provide(prodEnv) for provide(testEnv).
6565+ *
6666+ * 3. ERRORS BECOME UNKNOWN - Type information is lost
6767+ * lastError is `unknown`. We throw it, catch it, and lose all structure.
6868+ * In with-purus.ts, JobError flows through typed - no guessing.
6969+ *
7070+ * THE KEY INSIGHT: Promise.race is not cancellation.
7171+ * Run both examples and watch the logs - see how purus actually stops work.
7272+ *
7373+ * Prerequisites: http-client and workflow-engine examples
7474+ * Next: Compare with with-purus.ts
7575+ */
7676+7777+type Job = {
7878+ id: string
7979+ type: "email" | "image" | "sync"
8080+ payload: Record<string, unknown>
8181+}
8282+8383+// =============================================================================
8484+// SECTION 1: Hardcoded Logger (The Problem)
8585+// =============================================================================
8686+//
8787+// This logger is a module-level constant.
8888+// In tests, you'd typically need:
8989+// - jest.mock() to replace it
9090+// - process.env.NODE_ENV checks to disable logging
9191+// - A mocking library
9292+//
9393+// Compare with-purus.ts where you just swap provide(prodEnv) with provide(testEnv).
9494+// =============================================================================
9595+9696+const logger = {
9797+ info: (msg: string) => console.log(`[INFO] ${msg}`),
9898+ error: (msg: string) => console.log(`[ERROR] ${msg}`),
9999+}
100100+101101+// =============================================================================
102102+// SECTION 2: Job Execution
103103+// =============================================================================
104104+//
105105+// A simple async function that simulates work.
106106+// No cleanup mechanism - when this starts, it runs to completion.
107107+// =============================================================================
108108+109109+const executeJob = async (job: Job): Promise<void> => {
110110+ const delay = Math.random() * 200 + 50
111111+ await new Promise(resolve => setTimeout(resolve, delay))
112112+113113+ // 30% chance of failure
114114+ if (Math.random() < 0.3) {
115115+ throw new Error(`Job ${job.id} failed: transient error`)
116116+ }
117117+118118+ logger.info(`Job ${job.id} (${job.type}) completed`)
119119+}
120120+121121+// =============================================================================
122122+// SECTION 3: Promise.race Timeout (The Problem)
123123+// =============================================================================
124124+//
125125+// !!! THIS IS THE KEY PROBLEM !!!
126126+//
127127+// Promise.race() returns when ONE promise settles.
128128+// BUT THE OTHER PROMISE KEEPS RUNNING!
129129+//
130130+// If executeJob takes 10 seconds and timeout is 5 seconds:
131131+// - Promise.race returns after 5 seconds with "Timeout"
132132+// - executeJob continues running for 5 more seconds
133133+// - Any side effects from executeJob still happen
134134+// - We've used resources for work we're "ignoring"
135135+//
136136+// In with-purus.ts, timeout() calls the cleanup function, which sets
137137+// cancelled=true and clearTimeout(). The job actually STOPS.
138138+// =============================================================================
139139+140140+const executeWithTimeout = async (job: Job, timeoutMs: number): Promise<void> => {
141141+ // Promise.race: Returns when first promise settles, but...
142142+ // THE LOSER KEEPS RUNNING IN THE BACKGROUND!
143143+ //
144144+ // If executeJob takes 10s and timeout is 5s:
145145+ // - We get "Timeout" after 5s
146146+ // - But executeJob runs for the full 10s anyway
147147+ // - Any side effects still happen
148148+ const result = await Promise.race([
149149+ executeJob(job),
150150+ new Promise<never>((_, reject) =>
151151+ setTimeout(() => reject(new Error("Timeout")), timeoutMs)
152152+ ),
153153+ ])
154154+ return result
155155+}
156156+157157+// =============================================================================
158158+// SECTION 4: Retry Logic
159159+// =============================================================================
160160+//
161161+// Standard retry loop with error accumulation.
162162+//
163163+// NOTICE: lastError is `unknown`. We've lost all type information about
164164+// what went wrong. The caller has to guess and instanceof-check.
165165+// =============================================================================
166166+167167+const executeWithRetry = async (
168168+ job: Job,
169169+ maxRetries: number,
170170+ timeoutMs: number
171171+): Promise<void> => {
172172+ let lastError: unknown // <- All error type information is lost here
173173+174174+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
175175+ try {
176176+ logger.info(`[Attempt ${attempt}/${maxRetries}] Processing job ${job.id}`)
177177+ await executeWithTimeout(job, timeoutMs)
178178+ return
179179+ } catch (e) {
180180+ lastError = e // <- e is unknown, we've lost all structure
181181+ logger.error(`Attempt ${attempt} failed: ${e instanceof Error ? e.message : e}`)
182182+ }
183183+ }
184184+185185+ throw lastError // <- Caller gets unknown, not a typed error
186186+}
187187+188188+// =============================================================================
189189+// SECTION 5: Queue Processing
190190+// =============================================================================
191191+//
192192+// Processes jobs sequentially with error recovery.
193193+// Note how we track success/failure but can't distinguish error types.
194194+// =============================================================================
195195+196196+const processQueue = async (jobs: Job[]): Promise<void> => {
197197+ const results: Array<{ job: Job; success: boolean; error?: unknown }> = []
198198+199199+ for (const job of jobs) {
200200+ try {
201201+ await executeWithRetry(job, 3, 5000)
202202+ results.push({ job, success: true })
203203+ } catch (e) {
204204+ // e is unknown - we can't distinguish timeout vs transient vs permanent
205205+ results.push({ job, success: false, error: e })
206206+ }
207207+ }
208208+209209+ const succeeded = results.filter(r => r.success).length
210210+ const failed = results.filter(r => !r.success).length
211211+ logger.info(`Queue complete: ${succeeded} succeeded, ${failed} failed`)
212212+}
213213+214214+// =============================================================================
215215+// SECTION 6: Demo
216216+// =============================================================================
217217+//
218218+// Run this and compare with with-purus.ts.
219219+// Notice how there's no way to swap out the logger for testing.
220220+// =============================================================================
221221+222222+const main = async () => {
223223+ console.log("=== Task Queue (without purus) ===\n")
224224+225225+ const jobs: Job[] = [
226226+ { id: "job-1", type: "email", payload: { to: "user@example.com" } },
227227+ { id: "job-2", type: "image", payload: { path: "/uploads/photo.jpg" } },
228228+ { id: "job-3", type: "sync", payload: { source: "db", target: "cache" } },
229229+ ]
230230+231231+ await processQueue(jobs)
232232+233233+ console.log("\n=== Done ===")
234234+}
235235+236236+main()
237237+ .catch(console.error)
238238+ .finally(() => process.exit(0))
239239+```
240240+241241+242242+## Full Code: with-purus.ts
243243+244244+```typescript
245245+/**
246246+ * Task Queue Example - Concurrency and Dependency Injection
247247+ * ==========================================================
248248+ *
249249+ * This example teaches three advanced purus concepts:
250250+ *
251251+ * 1. REAL CANCELLATION - When timeout() fires, the job actually stops
252252+ * Promise.race just ignores the loser - it keeps running in the background.
253253+ * purus calls the cleanup function, which clears the timer and sets cancelled=true.
254254+ *
255255+ * 2. DEPENDENCY INJECTION - provide() and accessEff() for testability
256256+ * The QueueEnv type defines what dependencies the effect needs.
257257+ * provide(prodEnv) supplies production logger, provide(testEnv) supplies silent one.
258258+ * No DI framework needed - it's just functions and types.
259259+ *
260260+ * 3. TYPED ERRORS THROUGH THE PIPELINE - No error information lost
261261+ * JobError is a union type. Every handler knows exactly what can fail.
262262+ * Compare with without-purus.ts where errors become `unknown`.
263263+ *
264264+ * Compare with without-purus.ts to see:
265265+ * - Promise.race that doesn't actually cancel (line 41-48)
266266+ * - Hardcoded logger that's awkward to mock (line 19-22)
267267+ * - Lost error types through the pipeline
268268+ *
269269+ * Prerequisites: http-client and workflow-engine examples
270270+ * This is the most advanced example - it combines effects, DI, and concurrency.
271271+ */
272272+273273+import {
274274+ type Eff,
275275+ Exit,
276276+ succeed,
277277+ fail,
278278+ async,
279279+ flatMap,
280280+ mapEff,
281281+ catchAll,
282282+ accessEff,
283283+ provide,
284284+ timeout,
285285+ retry,
286286+ allSequential,
287287+ pipe,
288288+ match,
289289+ runPromise,
290290+} from "../../src/index"
291291+292292+// =============================================================================
293293+// SECTION 1: Types
294294+// =============================================================================
295295+//
296296+// Job: The unit of work to process
297297+// JobError: What can go wrong (typed, not `unknown`)
298298+// QueueEnv: Dependencies that the effect requires (injected at runtime)
299299+// =============================================================================
300300+301301+type Job = {
302302+ id: string
303303+ type: "email" | "image" | "sync"
304304+ payload: Record<string, unknown>
305305+}
306306+307307+// Typed errors - we know exactly what can fail
308308+type JobError =
309309+ | { readonly _tag: "TransientError"; readonly jobId: string; readonly message: string }
310310+ | { readonly _tag: "TimeoutError"; readonly jobId: string; readonly ms: number }
311311+312312+const JobError = {
313313+ transient: (jobId: string, message: string): JobError =>
314314+ ({ _tag: "TransientError", jobId, message }),
315315+ timeout: (jobId: string, ms: number): JobError =>
316316+ ({ _tag: "TimeoutError", jobId, ms }),
317317+}
318318+319319+// =============================================================================
320320+// SECTION 2: Dependency Injection with Environments
321321+// =============================================================================
322322+//
323323+// THE PROBLEM WITH HARDCODED DEPENDENCIES:
324324+// In without-purus.ts, the logger is a module-level constant.
325325+// To test silently, you'd need to mock the module or set NODE_ENV.
326326+//
327327+// THE SOLUTION - ENVIRONMENT TYPES:
328328+// 1. Define an interface (QueueEnv) that describes what the effect needs
329329+// 2. Use accessEff() to read from the environment
330330+// 3. Use provide() to supply the actual implementation
331331+//
332332+// HOW IT WORKS:
333333+// - Eff<A, E, R> has an R type parameter - the "requirements"
334334+// - processJob returns Eff<void, never, QueueEnv> - it REQUIRES QueueEnv
335335+// - provide(prodEnv) supplies QueueEnv, changing R from QueueEnv to unknown
336336+//
337337+// TESTING: Just swap provide(prodEnv) with provide(testEnv) in tests.
338338+// =============================================================================
339339+340340+// Environment type - what the effect requires
341341+type QueueEnv = {
342342+ logger: {
343343+ info: (msg: string) => void
344344+ error: (msg: string) => void
345345+ }
346346+}
347347+348348+// Production environment - logs to console
349349+const prodEnv: QueueEnv = {
350350+ logger: {
351351+ info: (msg) => console.log(`[INFO] ${msg}`),
352352+ error: (msg) => console.log(`[ERROR] ${msg}`),
353353+ },
354354+}
355355+356356+// Test environment - silent logging for unit tests
357357+// Just swap provide(prodEnv) with provide(testEnv) in tests
358358+const _testEnv: QueueEnv = {
359359+ logger: {
360360+ info: () => {},
361361+ error: () => {},
362362+ },
363363+}
364364+365365+// =============================================================================
366366+// SECTION 3: Real Cancellation with async()
367367+// =============================================================================
368368+//
369369+// THE PROBLEM WITH Promise.race:
370370+// In without-purus.ts (line 41-48), Promise.race returns when one settles.
371371+// But THE LOSING PROMISE KEEPS RUNNING! If the job takes 10 seconds and
372372+// timeout is 5 seconds, the job runs for 10 seconds anyway - we just ignore it.
373373+//
374374+// THE SOLUTION - CLEANUP FUNCTIONS:
375375+// async() takes a register function that returns a cleanup function.
376376+// When timeout() fires (or manual interrupt), the cleanup function runs.
377377+//
378378+// HOW IT WORKS:
379379+// 1. Register function starts async work
380380+// 2. Returns cleanup function (clears timer, sets cancelled flag)
381381+// 3. On timeout: cleanup runs -> timer cleared -> no resume called
382382+// 4. The job actually STOPS, not just ignored
383383+//
384384+// GOTCHA: Check the cancelled flag before calling resume.
385385+// If cleanup ran, the fiber is done - calling resume causes problems.
386386+// =============================================================================
387387+388388+const executeJob = (job: Job): Eff<void, JobError, QueueEnv> =>
389389+ async((resume) => {
390390+ // IMPORTANT: This flag tracks whether we've been cancelled
391391+ // If true, we must NOT call resume - the fiber is already done
392392+ let cancelled = false
393393+394394+ const delay = Math.random() * 200 + 50
395395+396396+ const timeoutId = setTimeout(() => {
397397+ // CHECK BEFORE RESUME: If cancelled, the fiber is done
398398+ // Calling resume after cancellation causes undefined behavior
399399+ if (cancelled) return
400400+401401+ // 30% chance of failure for demo purposes
402402+ if (Math.random() < 0.3) {
403403+ resume(Exit.fail(JobError.transient(job.id, "transient error")))
404404+ } else {
405405+ resume(Exit.succeed(undefined))
406406+ }
407407+ }, delay)
408408+409409+ // CLEANUP FUNCTION: Called on timeout, interrupt, or race-loser
410410+ // This is the key difference from Promise.race - we actually clean up
411411+ return () => {
412412+ cancelled = true // Signal that we're done
413413+ clearTimeout(timeoutId) // Cancel the pending timer
414414+ }
415415+ })
416416+417417+// =============================================================================
418418+// SECTION 4: Job Pipeline
419419+// =============================================================================
420420+//
421421+// COMPOSABILITY IN ACTION:
422422+// The pipeline is built from simple, reusable combinators:
423423+// - accessEff() reads the environment for logging
424424+// - retry() wraps with retry logic
425425+// - timeout() adds cancellation timeout
426426+// - catchAll() recovers from errors
427427+//
428428+// Each piece is independent. Want more retries? Change one number.
429429+// Want a longer timeout? Change one number. No restructuring needed.
430430+// =============================================================================
431431+432432+const TIMEOUT_MS = 5000
433433+const MAX_RETRIES = 3
434434+435435+const processJob = (job: Job): Eff<void, never, QueueEnv> =>
436436+ pipe(
437437+ // accessEff gets the environment and runs an effect with it
438438+ accessEff((env: QueueEnv) =>
439439+ pipe(
440440+ succeed(undefined),
441441+ flatMap(() => (
442442+ env.logger.info(`Processing job ${job.id} (${job.type})`),
443443+ executeJob(job)
444444+ ))
445445+ )
446446+ ),
447447+448448+ // Retry up to MAX_RETRIES times on failure
449449+ retry(MAX_RETRIES),
450450+451451+ // Timeout after TIMEOUT_MS - ACTUALLY CANCELS (unlike Promise.race)
452452+ timeout(TIMEOUT_MS),
453453+454454+ // Convert timeout (null) to typed error
455455+ flatMap((result) =>
456456+ result === null
457457+ ? fail(JobError.timeout(job.id, TIMEOUT_MS))
458458+ : succeed(result)
459459+ ),
460460+461461+ // Error recovery - log and continue
462462+ // Note: error is JobError, not unknown - we know exactly what failed
463463+ catchAll((error: JobError) =>
464464+ accessEff((env: QueueEnv) => (
465465+ env.logger.error(
466466+ match(error)({
467467+ TimeoutError: ({ jobId, ms }) => `Job ${jobId} timed out after ${ms}ms`,
468468+ TransientError: ({ jobId, message }) => `Job ${jobId} failed: ${message}`,
469469+ })
470470+ ),
471471+ succeed(undefined)
472472+ ))
473473+ ),
474474+475475+ // Final log
476476+ flatMap(() =>
477477+ accessEff((env: QueueEnv) => (
478478+ env.logger.info(`Job ${job.id} completed`),
479479+ succeed(undefined)
480480+ ))
481481+ )
482482+ )
483483+484484+// =============================================================================
485485+// SECTION 5: Queue Processing
486486+// =============================================================================
487487+//
488488+// allSequential processes jobs one at a time.
489489+// For parallel processing, use `all()` instead.
490490+// =============================================================================
491491+492492+const processQueue = (jobs: Job[]): Eff<void, never, QueueEnv> =>
493493+ pipe(
494494+ allSequential(jobs.map(processJob)),
495495+ mapEff(() => undefined)
496496+ )
497497+498498+// =============================================================================
499499+// SECTION 6: Demo
500500+// =============================================================================
501501+//
502502+// NOTICE:
503503+// - provide(prodEnv) injects production logger into the entire pipeline
504504+// - For tests, you'd use provide(testEnv) for silent logging
505505+// - No DI framework, no mocking library - just functions and types
506506+// =============================================================================
507507+508508+const main = async () => {
509509+ console.log("=== Task Queue (with purus) ===\n")
510510+511511+ const jobs: Job[] = [
512512+ { id: "job-1", type: "email", payload: { to: "user@example.com" } },
513513+ { id: "job-2", type: "image", payload: { path: "/uploads/photo.jpg" } },
514514+ { id: "job-3", type: "sync", payload: { source: "db", target: "cache" } },
515515+ ]
516516+517517+ // provide() injects the environment into the effect
518518+ // Swap prodEnv with testEnv for unit tests - no code changes needed
519519+ const program = pipe(
520520+ processQueue(jobs),
521521+ provide(prodEnv) // <- Change to testEnv in tests
522522+ )
523523+524524+ await runPromise(program)
525525+526526+ console.log("\n=== Done ===")
527527+}
528528+529529+main()
530530+ .catch(console.error)
531531+ .finally(() => process.exit(0))
532532+```
···11+---
22+title: Workflow Engine
33+description: Order processing with branded types, typestate, and exhaustive matching.
44+sidebar:
55+ order: 2
66+---
77+88+Order processing where IDs can't be swapped and invalid transitions don't compile.
99+1010+## The Problem
1111+1212+Business logic has:
1313+- IDs that look alike (OrderId, CustomerId - both strings)
1414+- State transitions (can't ship before paying)
1515+- Error cases you might forget to handle
1616+1717+In vanilla TypeScript, swapping `orderId` and `customerId` compiles fine.
1818+Shipping an unpaid order throws at runtime.
1919+2020+## Run Both Versions
2121+2222+```bash
2323+bun run examples/workflow-engine/without-purus.ts
2424+bun run examples/workflow-engine/with-purus.ts
2525+```
2626+2727+## Without purus
2828+2929+status !== 'paid')`)
3030+- String matching in error handling
3131+- There's a bug in lookupOrder - can you spot it?
3232+3333+## With purus
3434+3535+See `with-purus.ts`:
3636+- Branded types: `OrderId` and `CustomerId` are incompatible
3737+- Typestate: `shipOrder(order: PaidOrder)` - can't pass a draft
3838+- match() forces you to handle every error case
3939+4040+## Key Takeaways
4141+4242+- Branded types catch ID mixups at compile time
4343+- Typestate turns runtime checks into type errors
4444+- Exhaustive matching means you can't forget error cases
4545+4646+4747+## Full Code: without-purus.ts
4848+4949+```typescript
5050+/**
5151+ * Workflow Engine - Vanilla TypeScript (Comparison)
5252+ * ==================================================
5353+ *
5454+ * This is the "before" version. Compare with with-purus.ts to see how
5555+ * branded types and typestate prevent entire categories of bugs.
5656+ *
5757+ * PROBLEMS THIS APPROACH HAS:
5858+ *
5959+ * 1. TYPE ALIASES DON'T PREVENT MIX-UPS
6060+ * OrderId, CustomerId, ProductId are all `string`.
6161+ * Swap them accidentally? Compiles fine, fails at runtime.
6262+ * See line 71 for a live example of this bug.
6363+ *
6464+ * 2. STATUS FIELD REQUIRES RUNTIME CHECKS
6565+ * Every function that cares about state must check `order.status`.
6666+ * Forget a check? Runtime error. The compiler can't help.
6767+ *
6868+ * 3. ERROR HANDLING IS MESSAGE-BASED
6969+ * We detect errors by checking if error.message.includes("something").
7070+ * What if someone changes the error message? Silent failure.
7171+ *
7272+ * THE BIG BUG: Look at buggyLookup() on line 71.
7373+ * Arguments are swapped, but TypeScript says nothing. Both are strings.
7474+ *
7575+ * Prerequisites: Basic TypeScript understanding
7676+ * Next: Compare with with-purus.ts to see the fix
7777+ */
7878+7979+// =============================================================================
8080+// SECTION 1: Type Aliases (The Problem)
8181+// =============================================================================
8282+//
8383+// These look like distinct types, but they're all just `string`.
8484+// TypeScript's structural typing means OrderId = CustomerId = ProductId.
8585+//
8686+// The type aliases are DOCUMENTATION ONLY - no compile-time enforcement.
8787+// =============================================================================
8888+8989+type OrderId = string
9090+type CustomerId = string
9191+type ProductId = string
9292+9393+// All three are just strings, so swapping them compiles fine.
9494+// This is a major source of runtime bugs in real codebases.
9595+9696+// =============================================================================
9797+// SECTION 2: Status Field (The Problem)
9898+// =============================================================================
9999+//
100100+// We encode state as a field value, not in the type system.
101101+// Every function must manually check status before proceeding.
102102+//
103103+// PROBLEMS:
104104+// - Forget a check? Runtime error.
105105+// - Add a new status? Find every place that checks status manually.
106106+// - Tests must cover every invalid state combination.
107107+// =============================================================================
108108+109109+type OrderStatus = "draft" | "paid" | "shipped"
110110+111111+type Order = {
112112+ id: OrderId
113113+ customerId: CustomerId
114114+ productId: ProductId
115115+ quantity: number
116116+ status: OrderStatus // <- State is a field, not part of the type
117117+}
118118+119119+// =============================================================================
120120+// SECTION 3: State Transitions with Runtime Checks
121121+// =============================================================================
122122+//
123123+// NOTICE: Every function starts with a status check.
124124+// If you forget the check, invalid transitions silently succeed until runtime.
125125+//
126126+// Compare with-purus.ts where:
127127+// - payOrder() ONLY accepts DraftOrder (enforced by type system)
128128+// - shipOrder() ONLY accepts PaidOrder (enforced by type system)
129129+// - No runtime checks needed
130130+// =============================================================================
131131+132132+const payOrder = (order: Order): Order => {
133133+ // RUNTIME CHECK: Must be draft to pay
134134+ // What if we forget this? Silent corruption of order state.
135135+ if (order.status !== "draft") {
136136+ throw new Error(`Cannot pay order in ${order.status} status`)
137137+ }
138138+ console.log(`[Payment] Processing payment for order ${order.id}`)
139139+ return { ...order, status: "paid" }
140140+}
141141+142142+const shipOrder = (order: Order): Order => {
143143+ // RUNTIME CHECK: Must be paid to ship
144144+ // Tests must cover "ship draft order" and "ship shipped order" cases
145145+ if (order.status !== "paid") {
146146+ throw new Error(`Cannot ship order in ${order.status} status`)
147147+ }
148148+ console.log(`[Shipping] Shipping order ${order.id}`)
149149+ return { ...order, status: "shipped" }
150150+}
151151+152152+// =============================================================================
153153+// THE SWAPPED ARGUMENTS BUG
154154+// =============================================================================
155155+//
156156+// This is the most common bug that branded types prevent.
157157+// Both orderId and customerId are strings, so TypeScript can't tell them apart.
158158+// =============================================================================
159159+160160+// Both arguments are strings - can you spot the bug below?
161161+const lookupOrder = (orderId: OrderId, customerId: CustomerId): Order | null => {
162162+ if (orderId === "order-1" && customerId === "cust-1") {
163163+ return {
164164+ id: orderId,
165165+ customerId: customerId,
166166+ productId: "prod-1",
167167+ quantity: 2,
168168+ status: "draft",
169169+ }
170170+ }
171171+ return null
172172+}
173173+174174+// =============================================================================
175175+// !!! THE BUG - THIS COMPILES FINE !!!
176176+// =============================================================================
177177+//
178178+// The arguments are BACKWARDS. customerId is passed as orderId and vice versa.
179179+// TypeScript sees string, string and says "looks good to me!"
180180+//
181181+// This returns null at runtime when you expect an order.
182182+// In a real app, this could be: wrong customer charged, order sent to wrong address, etc.
183183+// =============================================================================
184184+185185+const buggyLookup = () => {
186186+ const customerId: CustomerId = "cust-1"
187187+ const orderId: OrderId = "order-1"
188188+189189+ // ARGUMENTS ARE SWAPPED! But it compiles because both are strings.
190190+ const order = lookupOrder(customerId, orderId) // Oops - wrong order!
191191+ return order
192192+}
193193+194194+// =============================================================================
195195+// SECTION 4: Error Handling (The Problem)
196196+// =============================================================================
197197+//
198198+// We detect error types by sniffing the error message.
199199+//
200200+// PROBLEMS:
201201+// - Someone changes "Cannot pay" to "Unable to pay"? Our handling breaks silently.
202202+// - No exhaustive checking - add a new error and we might miss handling it.
203203+// - `unknown` type means we lose all error information.
204204+// =============================================================================
205205+206206+const formatError = (error: unknown): string => {
207207+ if (error instanceof Error) {
208208+ const message = error.message
209209+210210+ // MESSAGE SNIFFING: Fragile and breaks if messages change
211211+ if (message.includes("Cannot pay")) {
212212+ return "Payment failed: Order is not in draft status"
213213+ }
214214+ if (message.includes("Cannot ship")) {
215215+ return "Shipping failed: Order is not paid"
216216+ }
217217+ return `Unknown error: ${message}`
218218+ }
219219+ return "Unknown error"
220220+}
221221+222222+// =============================================================================
223223+// SECTION 5: Demo
224224+// =============================================================================
225225+//
226226+// NOTICE:
227227+// - shipOrder(draftOrder) compiles but throws at runtime (Test 2)
228228+// - buggyLookup() returns null unexpectedly (Test 3) - arguments were swapped
229229+// - Error handling relies on message string matching
230230+// =============================================================================
231231+232232+const main = () => {
233233+ console.log("=== Workflow Engine (without purus) ===\n")
234234+235235+ // ---------------------------------------------------------------------------
236236+ // Test 1: Successful order flow
237237+ // Shows: works when you call functions in the right order
238238+ // ---------------------------------------------------------------------------
239239+ console.log("--- Test 1: Successful order flow ---")
240240+ try {
241241+ const order: Order = {
242242+ id: "order-1",
243243+ customerId: "cust-1",
244244+ productId: "prod-1",
245245+ quantity: 2,
246246+ status: "draft",
247247+ }
248248+249249+ const paid = payOrder(order)
250250+ const shipped = shipOrder(paid)
251251+ console.log(`Order ${shipped.id} shipped successfully!\n`)
252252+ } catch (e: unknown) {
253253+ console.log(formatError(e))
254254+ }
255255+256256+ // ---------------------------------------------------------------------------
257257+ // Test 2: Invalid state transition
258258+ // Shows: calling shipOrder on a draft order COMPILES but FAILS at runtime
259259+ // ---------------------------------------------------------------------------
260260+ console.log("--- Test 2: Invalid state transition ---")
261261+ try {
262262+ const order: Order = {
263263+ id: "order-2",
264264+ customerId: "cust-1",
265265+ productId: "prod-1",
266266+ quantity: 1,
267267+ status: "draft",
268268+ }
269269+270270+ // THIS COMPILES! TypeScript sees Order -> Order and says "fine"
271271+ // But it throws at runtime because status !== "paid"
272272+ const shipped = shipOrder(order)
273273+ console.log(`Shipped: ${shipped.id}\n`)
274274+ } catch (e: unknown) {
275275+ console.log(`Error: ${formatError(e)}\n`)
276276+ }
277277+278278+ // ---------------------------------------------------------------------------
279279+ // Test 3: The swapped arguments bug in action
280280+ // Shows: buggyLookup returns null because arguments are backwards
281281+ // ---------------------------------------------------------------------------
282282+ console.log("--- Test 3: Swapped arguments bug ---")
283283+ const order = buggyLookup()
284284+ if (order === null) {
285285+ // This happens because we passed (customerId, orderId) instead of (orderId, customerId)
286286+ // In a real app, this could mean: wrong customer billed, order lost, etc.
287287+ console.log("Order not found (bug: arguments were swapped!)\n")
288288+ }
289289+290290+ console.log("=== Done ===")
291291+}
292292+293293+main()
294294+process.exit(0)
295295+```
296296+297297+298298+## Full Code: with-purus.ts
299299+300300+```typescript
301301+/**
302302+ * Workflow Engine Example - Type-Safe Business Logic
303303+ * ===================================================
304304+ *
305305+ * This example teaches three advanced purus concepts:
306306+ *
307307+ * 1. BRANDED TYPES - Create distinct types from primitives
308308+ * `type OrderId = string` doesn't prevent swapping OrderId and CustomerId.
309309+ * `type OrderId = Branded<string, "OrderId">` does - compiler catches mix-ups.
310310+ *
311311+ * 2. TYPESTATE - Encode state machines in the type system
312312+ * DraftOrder, PaidOrder, ShippedOrder are different types.
313313+ * shipOrder(draft) won't compile - you must pay first.
314314+ * No runtime status checks needed.
315315+ *
316316+ * 3. EXHAUSTIVE MATCHING - Handle all error cases or don't compile
317317+ * Add a new error variant? Every match() call that doesn't handle it
318318+ * becomes a compile error. No silent "forgot to handle this" bugs.
319319+ *
320320+ * Compare with without-purus.ts to see:
321321+ * - The swapped-argument bug that compiles fine (line 71 in that file)
322322+ * - Runtime status checks that typestate eliminates
323323+ * - Error message sniffing vs typed errors
324324+ *
325325+ * Prerequisites: http-client example (for basic purus patterns)
326326+ * Next: task-queue for concurrency and dependency injection
327327+ */
328328+329329+import {
330330+ type Branded,
331331+ type Entity,
332332+ type Result,
333333+ brand,
334334+ entity,
335335+ transition,
336336+ ok,
337337+ err,
338338+ match,
339339+ matchResult,
340340+} from "../../src/index"
341341+342342+// =============================================================================
343343+// SECTION 1: Branded Types
344344+// =============================================================================
345345+//
346346+// THE PROBLEM WITH TYPE ALIASES:
347347+// type OrderId = string
348348+// type CustomerId = string
349349+// const process = (oid: OrderId, cid: CustomerId) => ...
350350+// process(customerId, orderId) // <-- Compiles! Both are just strings.
351351+//
352352+// THE SOLUTION - BRANDED TYPES:
353353+// Branded<T, B> adds a phantom "brand" that makes types incompatible.
354354+// OrderId and CustomerId are both strings at runtime, but the compiler
355355+// treats them as distinct types.
356356+//
357357+// SMART CONSTRUCTOR PATTERN:
358358+// The OrderId() function creates branded values. You could add validation
359359+// here (e.g., check format, prefix) and return Option<OrderId> for safety.
360360+// =============================================================================
361361+362362+type OrderId = Branded<string, "OrderId">
363363+const OrderId = (s: string): OrderId => brand(s)
364364+365365+type CustomerId = Branded<string, "CustomerId">
366366+const CustomerId = (s: string): CustomerId => brand(s)
367367+368368+type ProductId = Branded<string, "ProductId">
369369+const ProductId = (s: string): ProductId => brand(s)
370370+371371+// TRY THIS: Swap the arguments in a function that takes OrderId and CustomerId.
372372+// TypeScript will catch it immediately - no more runtime surprises.
373373+374374+// =============================================================================
375375+// SECTION 2: Typestate Pattern
376376+// =============================================================================
377377+//
378378+// TRADITIONAL STATUS FIELD APPROACH:
379379+// type Order = { status: "draft" | "paid" | "shipped"; ... }
380380+// const ship = (order: Order) => {
381381+// if (order.status !== "paid") throw new Error("...") // Runtime check
382382+// ...
383383+// }
384384+//
385385+// THE PROBLEM: You can call ship(draftOrder) and it compiles. The error
386386+// only happens at runtime.
387387+//
388388+// TYPESTATE APPROACH:
389389+// Each state is a SEPARATE TYPE. DraftOrder, PaidOrder, ShippedOrder.
390390+// - payOrder() accepts DraftOrder, returns PaidOrder
391391+// - shipOrder() accepts PaidOrder, returns ShippedOrder
392392+// - shipOrder(draftOrder) is a COMPILE ERROR - can't even write invalid code
393393+//
394394+// HOW IT WORKS:
395395+// Entity<T, S> uses a phantom type S to track state.
396396+// transition<T, From, To>() creates a function that only accepts Entity<T, From>.
397397+// =============================================================================
398398+399399+type OrderData = {
400400+ readonly id: OrderId
401401+ readonly customerId: CustomerId
402402+ readonly productId: ProductId
403403+ readonly quantity: number
404404+}
405405+406406+// Three DISTINCT types - the state IS the type, not a field
407407+type DraftOrder = Entity<OrderData, "Draft">
408408+type PaidOrder = Entity<OrderData, "Paid">
409409+type ShippedOrder = Entity<OrderData, "Shipped">
410410+411411+// Typed errors - each has specific data for debugging
412412+type OrderError =
413413+ | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string }
414414+ | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId }
415415+ | { readonly _tag: "PaymentDeclined"; readonly reason: string }
416416+417417+const OrderError = {
418418+ invalidTransition: (from: string, to: string): OrderError =>
419419+ ({ _tag: "InvalidTransition", from, to }),
420420+ orderNotFound: (orderId: OrderId): OrderError =>
421421+ ({ _tag: "OrderNotFound", orderId }),
422422+ paymentDeclined: (reason: string): OrderError =>
423423+ ({ _tag: "PaymentDeclined", reason }),
424424+}
425425+426426+// =============================================================================
427427+// SECTION 3: State Transitions
428428+// =============================================================================
429429+//
430430+// KEY INSIGHT: The function signatures ARE the state machine documentation.
431431+// payOrder: (DraftOrder) => Result<PaidOrder, OrderError>
432432+// shipOrder: (PaidOrder) => ShippedOrder
433433+//
434434+// From these signatures alone, you know:
435435+// - You can only pay a draft order
436436+// - Payment can fail (returns Result)
437437+// - You can only ship a paid order
438438+// - Shipping never fails (returns ShippedOrder directly)
439439+//
440440+// TRY THIS: Call shipOrder with a DraftOrder. TypeScript stops you:
441441+// "Argument of type 'DraftOrder' is not assignable to parameter of type 'PaidOrder'"
442442+//
443443+// No runtime status checks. No exceptions. Just types.
444444+// =============================================================================
445445+446446+// SIGNATURE: DraftOrder -> Result<PaidOrder, OrderError>
447447+// Only accepts DraftOrder. Try passing a ShippedOrder - won't compile.
448448+const payOrder = (order: DraftOrder): Result<PaidOrder, OrderError> => (
449449+ console.log(`[Payment] Processing payment for order ${order.id}`),
450450+ ok(transition<OrderData, "Draft", "Paid">()(order))
451451+)
452452+453453+// SIGNATURE: PaidOrder -> ShippedOrder
454454+// Only accepts PaidOrder. No runtime check needed.
455455+const shipOrder = (order: PaidOrder): ShippedOrder => (
456456+ console.log(`[Shipping] Shipping order ${order.id}`),
457457+ transition<OrderData, "Paid", "Shipped">()(order)
458458+)
459459+460460+// Branded types in action - compare with without-purus.ts line 71
461461+const lookupOrder = (
462462+ orderId: OrderId, // First parameter is OrderId
463463+ customerId: CustomerId // Second parameter is CustomerId
464464+): Result<DraftOrder, OrderError> =>
465465+ orderId === "order-1" && customerId === "cust-1"
466466+ ? ok(entity<OrderData, "Draft">({
467467+ id: orderId,
468468+ customerId: customerId,
469469+ productId: ProductId("prod-1"),
470470+ quantity: 2,
471471+ }))
472472+ : err(OrderError.orderNotFound(orderId))
473473+474474+// =============================================================================
475475+// THE BUG THAT BRANDED TYPES PREVENT
476476+// =============================================================================
477477+//
478478+// In without-purus.ts, this compiles fine:
479479+// lookupOrder(customerId, orderId) // Swapped arguments!
480480+//
481481+// With branded types, TypeScript catches it:
482482+// Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'
483483+//
484484+// Uncomment the code below to see the error:
485485+// =============================================================================
486486+487487+// const buggyLookup = () => {
488488+// const customerId = CustomerId("cust-1")
489489+// const orderId = OrderId("order-1")
490490+// return lookupOrder(customerId, orderId) // Error! Types are swapped.
491491+// }
492492+493493+// =============================================================================
494494+// SECTION 4: Error Handling
495495+// =============================================================================
496496+//
497497+// WHY TYPED ERRORS MATTER:
498498+// Without types, you end up with error.message.includes("not found") - fragile!
499499+//
500500+// With typed errors:
501501+// - match() forces you to handle every variant
502502+// - Add a new error type? All unhandled match() calls become compile errors
503503+// - Each handler receives correctly narrowed type (e.g., { orderId } for NotFound)
504504+//
505505+// TRY THIS: Comment out one case below. TypeScript will complain.
506506+// =============================================================================
507507+508508+const formatError = (error: OrderError): string =>
509509+ match(error)({
510510+ InvalidTransition: ({ from, to }) =>
511511+ `Cannot transition order from ${from} to ${to}`,
512512+ OrderNotFound: ({ orderId }) =>
513513+ `Order ${orderId} not found`,
514514+ PaymentDeclined: ({ reason }) =>
515515+ `Payment declined: ${reason}`,
516516+ })
517517+518518+// =============================================================================
519519+// SECTION 5: Demo
520520+// =============================================================================
521521+//
522522+// NOTICE:
523523+// - No runtime status checks in the business logic
524524+// - matchResult handles success and error cases explicitly
525525+// - The type system guides you through valid state transitions
526526+// =============================================================================
527527+528528+const main = () => {
529529+ console.log("=== Workflow Engine (with purus) ===\n")
530530+531531+ // ---------------------------------------------------------------------------
532532+ // Test 1: Successful order flow
533533+ // Shows: typestate ensures Draft -> Paid -> Shipped sequence
534534+ // ---------------------------------------------------------------------------
535535+ console.log("--- Test 1: Successful order flow ---")
536536+537537+ const orderId = OrderId("order-1")
538538+ const customerId = CustomerId("cust-1")
539539+540540+ const result = lookupOrder(orderId, customerId)
541541+542542+ matchResult<DraftOrder, OrderError, void>(
543543+ (draft) => {
544544+ // draft is DraftOrder - we can call payOrder
545545+ const payResult = payOrder(draft)
546546+ matchResult<PaidOrder, OrderError, void>(
547547+ (paid) => {
548548+ // paid is PaidOrder - we can call shipOrder
549549+ const shipped = shipOrder(paid)
550550+ console.log(`Order ${shipped.id} shipped successfully!\n`)
551551+ },
552552+ (error) => console.log(`Error: ${formatError(error)}\n`)
553553+ )(payResult)
554554+ },
555555+ (error) => console.log(`Error: ${formatError(error)}\n`)
556556+ )(result)
557557+558558+ // ---------------------------------------------------------------------------
559559+ // Test 2: Type safety demonstration
560560+ // Shows: invalid state transitions are compile errors, not runtime errors
561561+ // ---------------------------------------------------------------------------
562562+ console.log("--- Test 2: Type safety (compile-time protection) ---")
563563+ console.log("The following would be compile errors in purus:")
564564+ console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder")
565565+ console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId")
566566+ console.log("These bugs are caught at compile time, not runtime!\n")
567567+568568+ // ---------------------------------------------------------------------------
569569+ // Test 3: Error handling
570570+ // Shows: typed errors flow through Result, match() handles them
571571+ // ---------------------------------------------------------------------------
572572+ console.log("--- Test 3: Order not found ---")
573573+ const notFoundResult = lookupOrder(OrderId("invalid"), customerId)
574574+ matchResult<DraftOrder, OrderError, void>(
575575+ (draft) => console.log(`Found: ${draft.id}`),
576576+ (error) => console.log(`Error: ${formatError(error)}\n`)
577577+ )(notFoundResult)
578578+579579+ console.log("=== Done ===")
580580+}
581581+582582+main()
583583+process.exit(0)
584584+```
+90
docs-site/src/content/docs/index.mdx
···11+---
22+title: purus-ts
33+description: Pure functional programming in TypeScript — effects, fibers, branded types, and more.
44+template: splash
55+hero:
66+ title: purus-ts
77+ tagline: Pure functional programming in TypeScript — effects, fibers, branded types, and more.
88+ actions:
99+ - text: Start the Tutorial
1010+ link: /tutorial/
1111+ icon: right-arrow
1212+ variant: primary
1313+ - text: Read the Stories
1414+ link: /stories/
1515+ variant: minimal
1616+---
1717+1818+import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components";
1919+2020+## Choose Your Path
2121+2222+<CardGrid>
2323+ <Card title="Tutorial" icon="open-book">
2424+ Build a complete app step-by-step, learning one concept at a time. **~2-3
2525+ hours.**
2626+2727+ [Start learning →](/tutorial/)
2828+ </Card>
2929+3030+ <Card title="Concepts" icon="puzzle">
3131+ Deep-dives into specific topics. Each article is self-contained with
3232+ real-world examples.
3333+3434+ [Browse concepts →](/concepts/)
3535+ </Card>
3636+3737+ <Card title="Stories" icon="star">
3838+ Narrative-driven tutorials where animal characters discover FP concepts by
3939+ building real software.
4040+4141+ [Read stories →](/stories/)
4242+ </Card>
4343+4444+ <Card title="Examples" icon="laptop">
4545+ Side-by-side comparisons: vanilla TypeScript vs. purus-ts in real-world
4646+ scenarios.
4747+4848+ [See examples →](/examples/)
4949+ </Card>
5050+</CardGrid>
5151+5252+---
5353+5454+## Quick Reference
5555+5656+| Problem | Solution |
5757+| ----------------------------------- | ------------------------ |
5858+| "This function can fail" | `Result<T, E>` |
5959+| "This value might not exist" | `Option<T>` |
6060+| "I need to do async work" | `Eff<A, E, R>` |
6161+| "I don't want to mix up IDs" | `Branded<T, B>` |
6262+| "I need to inject dependencies" | `provide()` / `access()` |
6363+| "I need timeout/retry/cancellation" | Effect combinators |
6464+6565+## Import Patterns
6666+6767+```typescript
6868+// Core types and functions
6969+import {
7070+ type Result, ok, err, matchResult, chainResult, mapResult,
7171+ traverseResult, sequenceResult, orElseResult, fromNullableResult,
7272+ type Option, some, none, matchOption,
7373+ traverseOption, sequenceOption, orElseOption,
7474+ type Eff, succeed, fail, flatMap, pipe,
7575+ type Branded, brand,
7676+} from "purus-ts";
7777+7878+// Effect runners
7979+import { runPromise, runPromiseExit } from "purus-ts";
8080+8181+// Effect combinators
8282+import { zip, zipWith, zip3, ensure, tapErr, bracket } from "purus-ts";
8383+import { timeout, retry, race, all } from "purus-ts";
8484+8585+// Validation
8686+import { valid, invalid, validate2, validate3, validate4 } from "purus-ts";
8787+8888+// Pattern matching (custom discriminant)
8989+import { match, matchOn, matchOnOr } from "purus-ts";
9090+```
+66
docs-site/src/content/docs/stories/index.mdx
···11+---
22+title: Stories
33+description: "Narrative-driven functional programming tutorials with whimsical animal characters."
44+---
55+66+import { Card, CardGrid } from "@astrojs/starlight/components";
77+88+**Stories** are narrative-driven functional programming tutorials. Each story is a whimsical tale where animal characters discover and build the same concepts you'll use in production code.
99+1010+---
1111+1212+## Why Stories?
1313+1414+Traditional tutorials show you _what_ to type. Stories show you _why_ these patterns exist — through characters who face the same problems you do and discover the same solutions.
1515+1616+When Rabbit worries "But what if someone votes twice?", you feel the problem. When Owl scratches out a `BallotError` type, you understand why typed errors matter. The code isn't an afterthought; it's woven into the narrative as the characters write it.
1717+1818+---
1919+2020+## How to Read
2121+2222+1. **Follow the narrative** — Let the story carry you through the concepts
2323+2. **Read the code** — Characters write real, runnable TypeScript
2424+3. **Run the example** — Each story has a matching file in `examples/stories/`
2525+4. **Try variations** — Modify the example to solidify your understanding
2626+2727+---
2828+2929+## Available Stories
3030+3131+### [The Forest Election](/stories/forest-election/)
3232+3333+A trilogy about building a fair election system for the forest.
3434+3535+| Part | Title | Concept | Status |
3636+| ---- | ----------------------------------------------------------------------------- | ---------- | --------- |
3737+| 1 | [The Ballot Box Problem](/stories/forest-election/01-the-ballot-box-problem/) | Validation | Available |
3838+| 2 | [Counting Day](/stories/forest-election/02-counting-day/) | Result | Available |
3939+| 3 | [The Announcement](/stories/forest-election/03-the-announcement/) | Effects | Available |
4040+4141+**Tone:** Cozy and earnest (think Frog and Toad, Winnie the Pooh). The animals genuinely care about building fair software.
4242+4343+**Characters:** Owl (methodical thinker), Beaver (eager builder), Fox (pragmatic skeptic), Rabbit (anxious but thorough), Badger (wise elder).
4444+4545+---
4646+4747+### [Beaver's Big System](/stories/beavers-big-system/)
4848+4949+A sequel trilogy about building maintainable maintenance software.
5050+5151+| Part | Title | Concepts | Status |
5252+| ---- | ------------------------------------------------------------------------ | ---------------------------- | --------- |
5353+| 1 | [The Mixup](/stories/beavers-big-system/01-the-mixup/) | Branded Types + Typestate | Available |
5454+| 2 | [The Categorization](/stories/beavers-big-system/02-the-categorization/) | ADTs + Pattern Matching | Available |
5555+| 3 | [The Queue](/stories/beavers-big-system/03-the-queue/) | Tracked Arrays + Refinements | Available |
5656+5757+**Setup:** After the election, Beaver builds "The System" — a work order tracker. But strings everywhere lead to chaos: mixed-up IDs, invalid states, unsorted queues. Owl guides the rebuild.
5858+5959+**Same characters** continue their journey, learning compile-time safety through infrastructure problems.
6060+6161+---
6262+6363+## See Also
6464+6565+- [Tutorial](/tutorial/) — Step-by-step guide building a complete application
6666+- [Concepts](/concepts/) — Deep-dive reference articles
···11-# purus-ts Guides
22-33-Learn purus-ts through progressive tutorials and concept deep-dives.
44-55-## Learning Paths
66-77-### New to Functional TypeScript?
88-99-Start with the [Tutorial](./tutorial/) - it builds a complete application step-by-step, introducing one concept at a time. By the end, you'll understand:
1010-1111-- Why errors as values beat exceptions
1212-- How branded types prevent bugs at compile time
1313-- How the effect system makes async code composable
1414-- How dependency injection works without frameworks
1515-1616-### Already Know FP Basics?
1717-1818-Jump to the [Concepts](./concepts/) section for deep-dives into specific topics. Each article is self-contained and includes real-world examples.
1919-2020-### Prefer Learning Through Narrative?
2121-2222-Try the [Stories](./stories/) — whimsical tales where animal characters discover functional programming concepts by building real software. Each story is a gentle introduction that weaves code into narrative.
2323-2424----
2525-2626-## [Tutorial](./tutorial/)
2727-2828-Build a complete application step-by-step, learning one concept at a time.
2929-3030-**Duration:** ~2-3 hours total
3131-**Prerequisites:** Basic TypeScript knowledge
3232-3333-| Chapter | Topic | What You'll Learn |
3434-|---------|-----------------------------|-----------------------------------------------|
3535-| 1 | Why Functional TypeScript? | The problem with exceptions, errors as values |
3636-| 2 | Your First Effect | Creating, running, and transforming effects |
3737-| 3 | Typed Errors with Result | Ok/Err pattern, error composition |
3838-| 4 | Optional Values with Option | Some/None, avoiding null checks |
3939-| 5 | Pattern Matching | Exhaustive matching, type narrowing |
4040-| 6 | Branded Types | Distinct types from primitives |
4141-| 7 | The Effect System | Eff type, lazy evaluation, composition |
4242-| 8 | Concurrency with Fibers | Fork, join, race, cancellation |
4343-| 9 | Dependency Injection | Environments, provide, access |
4444-| 10 | Building a Complete App | Putting it all together |
4545-4646----
4747-4848-## [Concepts](./concepts/)
4949-5050-Deep-dives into specific topics when you need more detail.
5151-5252-| Article | Description |
5353-|---------------------------------------------------------|--------------------------------------------------------|
5454-| [Why These Strange Names?](./concepts/00-why-these-strange-names.md) | Demystifying FP terminology |
5555-| [Why Errors as Values?](./concepts/01-errors-as-values.md) | The exception problem, railway oriented programming |
5656-| [Branded Types In Depth](./concepts/02-branded-types.md) | Phantom types, smart constructors, production patterns |
5757-| [Typestate Pattern](./concepts/05-typestate-pattern.md) | State machines in the type system |
5858-| [Effect Composition](./concepts/06-effect-composition.md) | Building complex effects from simple ones |
5959-| [Fiber Internals](./concepts/07-fiber-internals.md) | How the runtime works under the hood |
6060-| [Dependency Injection Patterns](./concepts/08-dependency-injection-patterns.md) | Testing, layered environments |
6161-| [Testing Strategies](./concepts/09-testing-strategies.md) | Unit testing effectful code |
6262-6363----
6464-6565-## [Stories](./stories/)
6666-6767-Learn through narrative — whimsical tales where animal characters discover FP concepts.
6868-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 |
7373-7474----
7575-7676-## Quick Reference
7777-7878-### When to Use What
7979-8080-| Problem | Solution |
8181-|-------------------------------------|--------------------------|
8282-| "This function can fail" | `Result<T, E>` |
8383-| "This value might not exist" | `Option<T>` |
8484-| "I need to do async work" | `Eff<A, E, R>` |
8585-| "I don't want to mix up IDs" | `Branded<T, B>` |
8686-| "I need to inject dependencies" | `provide()` / `access()` |
8787-| "I need timeout/retry/cancellation" | Effect combinators |
8888-8989-### Import Patterns
9090-9191-```typescript
9292-// Core types and functions
9393-import {
9494- type Result, ok, err, matchResult, chainResult, mapResult,
9595- traverseResult, sequenceResult, orElseResult, fromNullableResult,
9696- type Option, some, none, matchOption,
9797- traverseOption, sequenceOption, orElseOption,
9898- type Eff, succeed, fail, flatMap, pipe,
9999- type Branded, brand,
100100-} from "purus-ts"
101101-102102-// Effect runners
103103-import { runPromise, runPromiseExit } from "purus-ts"
104104-105105-// Effect combinators
106106-import { zip, zipWith, zip3, ensure, tapErr, bracket } from "purus-ts"
107107-import { 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"
114114-```
···11-# Why These Strange Names?
11+---
22+title: Why These Strange Names?
33+description: Demystifying Functor, Monad, and other FP terminology with real-world analogies.
44+sidebar:
55+ order: 0
66+---
2738If you've encountered terms like "Monad" or "Functor" and felt intimidated, you're not alone. This article demystifies all the FP terminology used in purus-ts before you dive into the concepts.
49···393398394399Now that you know what the names mean, dive into the details:
395400396396-- [Why Errors as Values?](./01-errors-as-values.md) - The foundation: Result type
397397-- [Branded Types In Depth](./02-branded-types.md) - Compile-time type safety
398398-- [Validation and Error Accumulation](./03-validation-and-error-accumulation.md) - Applicative in action
399399-- [Type Classes in TypeScript](./04-type-classes-in-typescript.md) - Functor, Applicative, Monad patterns
401401+- [Why Errors as Values?](/concepts/01-errors-as-values/) - The foundation: Result type
402402+- [Branded Types In Depth](/concepts/02-branded-types/) - Compile-time type safety
403403+- [Validation and Error Accumulation](/concepts/03-validation-and-error-accumulation/) - Applicative in action
404404+- [Type Classes in TypeScript](/concepts/04-type-classes-in-typescript/) - Functor, Applicative, Monad patterns
···11-# Why Errors as Values?
11+---
22+title: Why Errors as Values?
33+description: The fundamental shift from exceptions to typed errors and railway-oriented programming.
44+sidebar:
55+ order: 1
66+---
2738This article explains the fundamental shift from throwing exceptions to returning typed errors, and why this change makes your TypeScript code more reliable.
49···449454450455## See Also
451456452452-- [Tutorial Chapter 1: Why Functional TypeScript?](../tutorial/01-why-functional-typescript.md) - Introduction with examples
453453-- [Tutorial Chapter 3: Typed Errors with Result](../tutorial/03-typed-errors-with-result.md) - Hands-on practice
454454-- [Branded Types In Depth](./02-branded-types.md) - Another compile-time safety technique
457457+- [Tutorial Chapter 1: Why Functional TypeScript?](/tutorial/01-why-functional-typescript/) - Introduction with examples
458458+- [Tutorial Chapter 3: Typed Errors with Result](/tutorial/03-typed-errors-with-result/) - Hands-on practice
459459+- [Branded Types In Depth](/concepts/02-branded-types/) - Another compile-time safety technique
···11-# Validation and Error Accumulation
11+---
22+title: Validation and Error Accumulation
33+description: Collecting all validation errors instead of stopping at the first one.
44+sidebar:
55+ order: 3
66+---
2738This article explains why Result's short-circuit behavior isn't always what you want, and how the Validation type accumulates all errors instead of stopping at the first.
49···495500 form => console.log("Valid registration:", form),
496501 errors => {
497502 console.log("Validation errors:")
498498- errors.forEach(e => console.log(` - ${formatError(e)}`))
503503+ console.log(errors.map(e => ` - ${formatError(e)}`).join("\n"))
499504 }
500505)(result)
501506···588593589594## See Also
590595591591-- [Tutorial Chapter 3: Typed Errors with Result](../tutorial/03-typed-errors-with-result.md) - Result fundamentals
592592-- [Why Errors as Values?](./01-errors-as-values.md) - The philosophy behind typed errors
593593-- [Type Classes in TypeScript](./04-type-classes-in-typescript.md) - Monads vs Applicatives explained
596596+- [Tutorial Chapter 3: Typed Errors with Result](/tutorial/03-typed-errors-with-result/) - Result fundamentals
597597+- [Why Errors as Values?](/concepts/01-errors-as-values/) - The philosophy behind typed errors
598598+- [Type Classes in TypeScript](/concepts/04-type-classes-in-typescript/) - Monads vs Applicatives explained
···11-# Type Classes in TypeScript
11+---
22+title: Type Classes in TypeScript
33+description: Understanding Functor, Applicative, Monad, and Bifunctor patterns.
44+sidebar:
55+ order: 4
66+---
2738This article explains functional programming type class patterns and how purus implements them within TypeScript's type system limitations.
49···120125121126**purus opts for simplicity:** explicit functions per type (`mapResult`, `mapOption`, `mapEff`) with consistent patterns. You lose some abstraction but gain immediate readability.
122127123123-> **New to FP terminology?** Read [Why These Strange Names?](./00-why-these-strange-names.md) first for plain-English explanations and real-world analogies.
128128+> **New to FP terminology?** Read [Why These Strange Names?](/concepts/00-why-these-strange-names/) first for plain-English explanations and real-world analogies.
124129125130---
126131···704709705710## See Also
706711707707-- [Validation and Error Accumulation](./03-validation-and-error-accumulation.md) - Applicative in action
708708-- [Why Errors as Values?](./01-errors-as-values.md) - Result fundamentals
709709-- [Effect Composition](./06-effect-composition.md) - How effects compose internally
710710-- [Tutorial Chapter 3: Typed Errors with Result](../tutorial/03-typed-errors-with-result.md) - Hands-on Result practice
712712+- [Validation and Error Accumulation](/concepts/03-validation-and-error-accumulation/) - Applicative in action
713713+- [Why Errors as Values?](/concepts/01-errors-as-values/) - Result fundamentals
714714+- [Effect Composition](/concepts/06-effect-composition/) - How effects compose internally
715715+- [Tutorial Chapter 3: Typed Errors with Result](/tutorial/03-typed-errors-with-result/) - Hands-on Result practice
···11-# Typestate Pattern
11+---
22+title: Typestate Pattern
33+description: "Encoding state machines in the type system so invalid states don't compile."
44+sidebar:
55+ order: 5
66+---
2738This article explains how to encode state machines in the type system, making invalid state transitions impossible at compile time.
49···319324320325## See Also
321326322322-- [Branded Types In Depth](./02-branded-types.md) - Foundation for phantom types
323323-- [Workflow Engine Example](../../../examples/workflow-engine/) - Full order processing system
327327+- [Branded Types In Depth](/concepts/02-branded-types/) - Foundation for phantom types
328328+- [Workflow Engine Example](/examples/workflow-engine/) - Full order processing system
···11-# Fiber Internals
11+---
22+title: Fiber Internals
33+description: How the purus runtime executes effects with trampolining and scheduling.
44+sidebar:
55+ order: 7
66+---
2738This article explains how purus executes effects under the hood: the trampoline pattern, stack safety, and fiber lifecycle.
49···337342338343## See Also
339344340340-- [Effect Composition](./06-effect-composition.md) - How effects combine
341341-- [Testing Strategies](./09-testing-strategies.md) - Using Exit for assertions
345345+- [Effect Composition](/concepts/06-effect-composition/) - How effects combine
346346+- [Testing Strategies](/concepts/09-testing-strategies/) - Using Exit for assertions
···11-# Dependency Injection Patterns
11+---
22+title: Dependency Injection Patterns
33+description: Testable code without frameworks using the Reader pattern and environments.
44+sidebar:
55+ order: 8
66+---
2738This article explains how to write testable code using purus's built-in dependency injection: `access`, `accessEff`, and `provide`.
49···388393389394## See Also
390395391391-- [Testing Strategies](./09-testing-strategies.md) - More testing patterns
392392-- [HTTP Client Example](../../../examples/http-client/) - DI in practice
396396+- [Testing Strategies](/concepts/09-testing-strategies/) - More testing patterns
397397+- [HTTP Client Example](/examples/http-client/) - DI in practice
···11-# purus-ts Concepts
11+---
22+title: Concepts
33+description: Deep-dives into specific topics when you need more detail.
44+---
2536Deep-dives into specific topics when you need more detail.
47···811912## Articles
10131111-### [00 - Why These Strange Names?](./00-why-these-strange-names.md)
1414+### [00 - Why These Strange Names?](/concepts/00-why-these-strange-names/)
12151316Demystifying Functor, Monad, and other FP terminology.
1417···22252326---
24272525-### [01 - Why Errors as Values?](./01-errors-as-values.md)
2828+### [01 - Why Errors as Values?](/concepts/01-errors-as-values/)
26292730The fundamental shift from exceptions to typed errors.
2831···37403841---
39424040-### [02 - Branded Types In Depth](./02-branded-types.md)
4343+### [02 - Branded Types In Depth](/concepts/02-branded-types/)
41444245Creating distinct types from primitives for compile-time safety.
4346···52555356---
54575555-### [03 - Validation and Error Accumulation](./03-validation-and-error-accumulation.md)
5858+### [03 - Validation and Error Accumulation](/concepts/03-validation-and-error-accumulation/)
56595760Collecting all validation errors instead of stopping at the first.
5861···67706871---
69727070-### [04 - Type Classes in TypeScript](./04-type-classes-in-typescript.md)
7373+### [04 - Type Classes in TypeScript](/concepts/04-type-classes-in-typescript/)
71747275Understanding Functor, Applicative, Monad, and Bifunctor patterns.
7376···82858386---
84878585-### [05 - Typestate Pattern](./05-typestate-pattern.md)
8888+### [05 - Typestate Pattern](/concepts/05-typestate-pattern/)
86898790Encoding state machines in the type system.
8891···9710098101---
99102100100-### [06 - Effect Composition](./06-effect-composition.md)
103103+### [06 - Effect Composition](/concepts/06-effect-composition/)
101104102105Building complex effects from simple building blocks.
103106···112115113116---
114117115115-### [07 - Fiber Internals](./07-fiber-internals.md)
118118+### [07 - Fiber Internals](/concepts/07-fiber-internals/)
116119117120How the purus runtime executes effects.
118121···127130128131---
129132130130-### [08 - Dependency Injection Patterns](./08-dependency-injection-patterns.md)
133133+### [08 - Dependency Injection Patterns](/concepts/08-dependency-injection-patterns/)
131134132135Testable code without frameworks.
133136···142145143146---
144147145145-### [09 - Testing Strategies](./09-testing-strategies.md)
148148+### [09 - Testing Strategies](/concepts/09-testing-strategies/)
146149147150Unit testing code that uses purus.
148151···157160158161---
159162163163+### [10 - Recursion Over Loops](/concepts/10-recursion-over-loops/)
164164+165165+Why pure functional code avoids loops and how to replace them with expressions.
166166+167167+**Topics Covered:**
168168+- Expressions vs statements
169169+- Why forEach returns void (and why that matters)
170170+- map, filter, reduce as loop replacements
171171+- The go() pattern for complex recursion
172172+- Head-tail decomposition
173173+- Performance considerations
174174+175175+**Best For:** Understanding the no-loops philosophy, converting imperative patterns.
176176+177177+---
178178+160179## Quick Concept Reference
161180162181| Concept | One-Liner |
···170189| Fiber | Lightweight thread with cancellation |
171190| Environment | Dependencies as a type parameter |
172191| Type Classes | Functor, Applicative, Monad patterns |
192192+| Recursion | Loops are statements; expressions compose |
173193174194---
175195176196## See Also
177197178178-- **[Tutorial](../tutorial/)** - Learn concepts in order by building an app
179179-- **[Examples](../../../examples/)** - Real-world code patterns
180180-- **[Source](../../../src/)** - The library is ~950 lines of readable TypeScript
198198+- **[Tutorial](/tutorial/)** - Learn concepts in order by building an app
199199+- **[Examples](/examples/)** - Real-world code patterns
200200+- **[Source](https://tangled.sh/oleksify.me/purus-ts/tree/main/src/)** - The library is ~950 lines of readable TypeScript
-61
docs/guides/stories/README.md
···11-# Stories
22-33-**Stories** are narrative-driven functional programming tutorials. Each story is a whimsical tale where animal characters discover and build the same concepts you'll use in production code.
44-55----
66-77-## Why Stories?
88-99-Traditional tutorials show you *what* to type. Stories show you *why* these patterns exist — through characters who face the same problems you do and discover the same solutions.
1010-1111-When Rabbit worries "But what if someone votes twice?", you feel the problem. When Owl scratches out a `BallotError` type, you understand why typed errors matter. The code isn't an afterthought; it's woven into the narrative as the characters write it.
1212-1313----
1414-1515-## How to Read
1616-1717-1. **Follow the narrative** — Let the story carry you through the concepts
1818-2. **Read the code** — Characters write real, runnable TypeScript
1919-3. **Run the example** — Each story has a matching file in `examples/stories/`
2020-4. **Try variations** — Modify the example to solidify your understanding
2121-2222----
2323-2424-## Available Stories
2525-2626-### [The Forest Election](./forest-election/)
2727-2828-A trilogy about building a fair election system for the forest.
2929-3030-| Part | Title | Concept | Status |
3131-|------|--------------------------------------------------------------------------|------------|-----------|
3232-| 1 | [The Ballot Box Problem](./forest-election/01-the-ballot-box-problem.md) | Validation | Available |
3333-| 2 | [Counting Day](./forest-election/02-counting-day.md) | Result | Available |
3434-| 3 | [The Announcement](./forest-election/03-the-announcement.md) | Effects | Available |
3535-3636-**Tone:** Cozy and earnest (think Frog and Toad, Winnie the Pooh). The animals genuinely care about building fair software.
3737-3838-**Characters:** Owl (methodical thinker), Beaver (eager builder), Fox (pragmatic skeptic), Rabbit (anxious but thorough), Badger (wise elder).
3939-4040----
4141-4242-### [Beaver's Big System](./beavers-big-system/)
4343-4444-A sequel trilogy about building maintainable maintenance software.
4545-4646-| Part | Title | Concepts | Status |
4747-|------|----------------------------------------------------------------------------|--------------------------------|-----------|
4848-| 1 | [The Mixup](./beavers-big-system/01-the-mixup.md) | Branded Types + Typestate | Available |
4949-| 2 | [The Categorization](./beavers-big-system/02-the-categorization.md) | ADTs + Pattern Matching | Available |
5050-| 3 | [The Queue](./beavers-big-system/03-the-queue.md) | Tracked Arrays + Refinements | Available |
5151-5252-**Setup:** After the election, Beaver builds "The System" — a work order tracker. But strings everywhere lead to chaos: mixed-up IDs, invalid states, unsorted queues. Owl guides the rebuild.
5353-5454-**Same characters** continue their journey, learning compile-time safety through infrastructure problems.
5555-5656----
5757-5858-## See Also
5959-6060-- [Tutorial](../tutorial/) — Step-by-step guide building a complete application
6161-- [Concepts](../concepts/) — Deep-dive reference articles
···11-# The Mixup
11+---
22+title: The Mixup
33+description: "Part 1 — the animals learn that not all strings are created equal."
44+sidebar:
55+ order: 1
66+ badge:
77+ text: Part 1
88+ variant: note
99+prev:
1010+ link: /stories/beavers-big-system/
1111+ label: "Beaver's Big System"
1212+next:
1313+ link: /stories/beavers-big-system/02-the-categorization/
1414+ label: "Part 2: The Categorization"
1515+---
1616+1717+import StoryIllustration from '../../../../components/StoryIllustration.astro';
1818+1919+<div class="story-chapter">
2020+2121+<StoryIllustration
2222+ alt="Beaver standing proudly next to The System, a tangled mess of strings and logs"
2323+ caption="The System — version 1.0"
2424+/>
225326*Part 1 of Beaver's Big System*
427···409432410433---
411434412412-*Next: [The Categorization](./02-the-categorization.md) — where the animals learn to distinguish different kinds of work*
435435+*Next: [The Categorization](/stories/beavers-big-system/02-the-categorization/) — where the animals learn to distinguish different kinds of work*
436436+437437+438438+</div>
···11-# The Categorization
11+---
22+title: The Categorization
33+description: "Part 2 — the animals learn that different work requires different data."
44+sidebar:
55+ order: 2
66+ badge:
77+ text: Part 2
88+ variant: note
99+prev:
1010+ link: /stories/beavers-big-system/01-the-mixup/
1111+ label: "Part 1: The Mixup"
1212+next:
1313+ link: /stories/beavers-big-system/03-the-queue/
1414+ label: "Part 3: The Queue"
1515+---
1616+1717+<div class="story-chapter">
218319*Part 2 of Beaver's Big System*
420···492508493509---
494510495495-*Next: [The Queue](./03-the-queue.md) — where the animals learn that arrays can track their own properties*
511511+*Next: [The Queue](/stories/beavers-big-system/03-the-queue/) — where the animals learn that arrays can track their own properties*
512512+513513+514514+</div>
···11-# The Queue
11+---
22+title: The Queue
33+description: "Part 3 — the animals learn that arrays can remember their properties."
44+sidebar:
55+ order: 3
66+ badge:
77+ text: Part 3
88+ variant: note
99+prev:
1010+ link: /stories/beavers-big-system/02-the-categorization/
1111+ label: "Part 2: The Categorization"
1212+---
1313+1414+<div class="story-chapter">
215316*Part 3 of Beaver's Big System*
417···457470458471| Part | Story | Concepts |
459472|------|-------|----------|
460460-| 1 | [The Mixup](./01-the-mixup.md) | Branded Types + Typestate |
461461-| 2 | [The Categorization](./02-the-categorization.md) | ADTs + Pattern Matching |
473473+| 1 | [The Mixup](/stories/beavers-big-system/01-the-mixup/) | Branded Types + Typestate |
474474+| 2 | [The Categorization](/stories/beavers-big-system/02-the-categorization/) | ADTs + Pattern Matching |
462475| 3 | The Queue (this story) | Tracked Arrays + Refinements |
463476464477Together, these patterns form a toolkit for *making invalid states unrepresentable*. The type system becomes your first line of defense against bugs — catching errors at compile time instead of runtime.
···466479---
467480468481*Return to [Beaver's Big System overview](./) or explore [The Forest Election](../forest-election/) trilogy*
482482+483483+484484+</div>
···11-# Beaver's Big System
11+---
22+title: "Beaver's Big System"
33+description: A trilogy about building maintainable maintenance software.
44+---
2536A story in three parts about building maintainable maintenance software.
47···29323033## Parts
31343232-### Part 1: [The Mixup](./01-the-mixup.md)
3535+### Part 1: [The Mixup](/stories/beavers-big-system/01-the-mixup/)
33363437Beaver's system uses plain strings for IDs and status. Fox accidentally marks `bridge-east` work as done using `dam-north`'s ID. Rabbit finds a work order with status `"in_progerss"` (typo). Badger discovers work marked `"done"` that was never `"started"`.
3538···43464447---
45484646-### Part 2: [The Categorization](./02-the-categorization.md)
4949+### Part 2: [The Categorization](/stories/beavers-big-system/02-the-categorization/)
47504851All work orders are treated identically, but different infrastructure needs different handling. Dam repairs need water level checks. Bridge work needs weight capacity assessments. Beaver's `type: string` field leads to `"Dam"`, `"dam"`, and `"water-structure"` chaos.
4952···57605861---
59626060-### Part 3: [The Queue](./03-the-queue.md)
6363+### Part 3: [The Queue](/stories/beavers-big-system/03-the-queue/)
61646265Work orders need prioritization. Someone calls `getNext()` on an empty queue — runtime crash. The "sorted by priority" list isn't actually sorted after appending. Negative priority values sneak in.
6366···92959396- [Stories overview](../) — Other available stories
9497- [The Forest Election](../forest-election/) — The prequel trilogy
9595-- [Branded Types and Refinements](../../concepts/01-branded-types-and-refinements.md) — Technical deep-dive
9898+- [Branded Types and Refinements](/concepts/01-branded-types-and-refinements/) — Technical deep-dive
···11-# The Ballot Box Problem
11+---
22+title: The Ballot Box Problem
33+description: "Part 1 — the animals learn that finding ALL problems is better than stopping at the first."
44+sidebar:
55+ order: 1
66+ badge:
77+ text: Part 1
88+ variant: note
99+prev:
1010+ link: /stories/forest-election/
1111+ label: The Forest Election
1212+next:
1313+ link: /stories/forest-election/02-counting-day/
1414+ label: "Part 2: Counting Day"
1515+---
21633-*Part 1 of The Forest Election*
1717+import StoryIllustration from "../../../../components/StoryIllustration.astro";
1818+1919+<div class="story-chapter">
2020+2121+<StoryIllustration
2222+ alt="The animals gathered around the hollow log ballot box"
2323+ caption="Election day in the forest"
2424+/>
2525+2626+_Part 1 of The Forest Election_
427528> In which the animals learn that finding ALL the problems
629> is better than stopping at the first one.
···37603861More leaves spilled out. Owl's pile of problems grew.
39624040-"Here's one from Rabbit, voting for Deer. And here's *another* one from Rabbit, also voting for Deer."
6363+"Here's one from Rabbit, voting for Deer. And here's _another_ one from Rabbit, also voting for Deer."
41644265"I was nervous!" Rabbit squeaked. "I couldn't remember if I'd already voted!"
4366···45684669Fox pushed off from his tree. "Simple solution. Throw out the bad ones, count what's left."
47704848-"But then we don't know what went wrong!" Rabbit wrung her paws. "What if someone's vote was supposed to count but we rejected it? What if we could have *fixed* it?"
7171+"But then we don't know what went wrong!" Rabbit wrung her paws. "What if someone's vote was supposed to count but we rejected it? What if we could have _fixed_ it?"
49725050-"Rabbit has a point," said Owl. "If we only stop at the first problem, we might miss others. Someone fixes their blank name, submits again, and *then* we tell them their candidate doesn't exist?"
7373+"Rabbit has a point," said Owl. "If we only stop at the first problem, we might miss others. Someone fixes their blank name, submits again, and _then_ we tell them their candidate doesn't exist?"
51745275"So what do you suggest?" Fox asked.
5376···55785679---
57805858-"A ballot," said Owl, scratching careful marks, "has structure. It's not just any leaf — it's a leaf with *requirements*."
8181+"A ballot," said Owl, scratching careful marks, "has structure. It's not just any leaf — it's a leaf with _requirements_."
59826083```typescript
6161-type Candidate = "Deer" | "Squirrel" | "Heron"
8484+type Candidate = "Deer" | "Squirrel" | "Heron";
62856386type Ballot = {
6464- readonly voter: string
6565- readonly candidate: Candidate
6666-}
8787+ readonly voter: string;
8888+ readonly candidate: Candidate;
8989+};
6790```
68916992"A voter name," she continued. "And a candidate that must be one of our three."
70937194Rabbit peered at the scratches. "But what happens when something's wrong?"
72957373-Owl made a new leaf. "Then we need to describe what's wrong. And there can be *multiple* things wrong with a single ballot."
9696+Owl made a new leaf. "Then we need to describe what's wrong. And there can be _multiple_ things wrong with a single ballot."
74977598```typescript
7699type BallotError =
77100 | { readonly _tag: "MissingVoter" }
78101 | { readonly _tag: "MissingCandidate" }
79102 | { readonly _tag: "InvalidCandidate"; readonly name: string }
8080- | { readonly _tag: "DuplicateVoter"; readonly voter: string }
103103+ | { readonly _tag: "DuplicateVoter"; readonly voter: string };
81104```
8210583106"Missing voter," Rabbit said, reading along. "Missing candidate. Invalid candidate — that's the Wolfe one. And... duplicate voter." She looked sheepish.
···95118 (error) (error)
96119```
971209898-"If the voter is missing, we stop. We never check the candidate. The submitter fixes the voter, resubmits, *then* discovers the candidate problem."
121121+"If the voter is missing, we stop. We never check the candidate. The submitter fixes the voter, resubmits, _then_ discovers the candidate problem."
99122100123"That's what Fox said," Beaver observed.
101124···128151import {
129152 type Validation,
130153 valid,
154154+ invalid,
131155 invalidOne,
132156 apValidation,
133157 matchValidation,
158158+ match,
134159 pipe,
135135-} from "purus-ts"
160160+} from "purus-ts";
136161```
137162138138-"A `Validation` is either valid, containing a value, or invalid, containing a *list* of errors. Not one error — a list."
163163+"A `Validation` is either valid, containing a value, or invalid, containing a _list_ of errors. Not one error — a list."
139164140165"Show us how it works," said Beaver.
141166···145170146171```typescript
147172const validateVoter = (
148148- voter: string | undefined
173173+ voter: string | undefined,
149174): Validation<string, BallotError> =>
150175 voter && voter.trim().length > 0
151176 ? valid(voter.trim())
152152- : invalidOne({ _tag: "MissingVoter" })
177177+ : invalidOne({ _tag: "MissingVoter" });
153178```
154179155180"If the voter exists and isn't blank, it's `valid`. Otherwise, `invalidOne` creates an invalid result with that single error."
···161186Beaver took a leaf:
162187163188```typescript
164164-const VALID_CANDIDATES: readonly Candidate[] = ["Deer", "Squirrel", "Heron"]
189189+const VALID_CANDIDATES: readonly Candidate[] = ["Deer", "Squirrel", "Heron"];
165190166191const validateCandidate = (
167167- candidate: string | undefined
192192+ candidate: string | undefined,
168193): Validation<Candidate, BallotError> =>
169194 !candidate || candidate.trim().length === 0
170195 ? invalidOne({ _tag: "MissingCandidate" })
171171- : VALID_CANDIDATES.includes(candidate as Candidate)
172172- ? valid(candidate as Candidate)
173173- : invalidOne({ _tag: "InvalidCandidate", name: candidate })
196196+ : pipe(
197197+ VALID_CANDIDATES.find((c) => c === candidate.trim()),
198198+ (found) =>
199199+ found !== undefined
200200+ ? valid(found)
201201+ : invalidOne({ _tag: "InvalidCandidate", name: candidate }),
202202+ );
174203```
175204176205"Good," said Owl. "If blank, missing. If not a valid candidate name, invalid. Otherwise, valid."
···179208180209"But we have two validators," said Fox. "How do they become one?"
181210182182-"This is where it gets interesting." Owl cleared a fresh patch of dirt. "We need to *combine* them. And the combining is what accumulates errors."
211211+"This is where it gets interesting." Owl cleared a fresh patch of dirt. "We need to _combine_ them. And the combining is what accumulates errors."
183212184213She wrote:
185214···189218 (candidate: Candidate): Ballot => ({
190219 voter,
191220 candidate,
192192- })
221221+ });
193222194223const validateBallot = (
195224 voter: string | undefined,
196196- candidate: string | undefined
225225+ candidate: string | undefined,
197226): Validation<Ballot, BallotError> =>
198227 pipe(
199228 valid(makeBallot),
200229 apValidation(validateVoter(voter)),
201201- apValidation(validateCandidate(candidate))
202202- )
230230+ apValidation(validateCandidate(candidate)),
231231+ );
203232```
204233205205-"Start with `valid(makeBallot)` — a validation containing a function that *builds* a ballot. Then `apValidation` applies each validator in turn."
234234+"Start with `valid(makeBallot)` — a validation containing a function that _builds_ a ballot. Then `apValidation` applies each validator in turn."
206235207236"The magic," she tapped the leaf, "is what happens when things go wrong."
208237···232261233262"In case 3, both errors are collected! That's accumulation."
234263235235-Rabbit's eyes widened. "So we know *everything* that's wrong. All at once."
264264+Rabbit's eyes widened. "So we know _everything_ that's wrong. All at once."
236265237266"Exactly."
238267···245274```typescript
246275const validateNoDuplicate = (
247276 voter: string,
248248- alreadyVoted: ReadonlySet<string>
277277+ alreadyVoted: ReadonlySet<string>,
249278): Validation<string, BallotError> =>
250279 alreadyVoted.has(voter)
251280 ? invalidOne({ _tag: "DuplicateVoter", voter })
252252- : valid(voter)
281281+ : valid(voter);
282282+```
283283+284284+"And we can combine this with the others." She started writing, then paused and separated the voter checks:
285285+286286+```typescript
287287+const validateVoterFull = (
288288+ voter: string | undefined,
289289+ alreadyVoted: ReadonlySet<string>,
290290+): Validation<string, BallotError> =>
291291+ matchValidation(
292292+ (v: string) => validateNoDuplicate(v, alreadyVoted),
293293+ (errors: readonly BallotError[]) => invalid<BallotError>(errors),
294294+ )(validateVoter(voter));
253295```
254296255255-"And we can combine this with the others." She rewrote the full validator:
297297+"First check if the voter exists. If valid, check for duplicates. If already invalid, pass the errors through."
298298+299299+Then the full validator became clean:
256300257301```typescript
258302const validateBallotFull = (
259303 voter: string | undefined,
260304 candidate: string | undefined,
261261- alreadyVoted: ReadonlySet<string>
305305+ alreadyVoted: ReadonlySet<string>,
262306): Validation<Ballot, BallotError> =>
263307 pipe(
264308 valid(makeBallot),
265265- apValidation(
266266- pipe(
267267- validateVoter(voter),
268268- // Only check duplicate if voter exists
269269- (v) =>
270270- v._tag === "Valid"
271271- ? pipe(
272272- validateNoDuplicate(v.value, alreadyVoted),
273273- // Keep the voter value if no duplicate
274274- (dup) => (dup._tag === "Valid" ? v : dup)
275275- )
276276- : v
277277- )
278278- ),
279279- apValidation(validateCandidate(candidate))
280280- )
309309+ apValidation(validateVoterFull(voter, alreadyVoted)),
310310+ apValidation(validateCandidate(candidate)),
311311+ );
281312```
282313283283-"Hmm," said Beaver. "That duplicate check is a bit tangled."
284284-285285-"It is," Owl admitted. "There's an alternative — run all validators independently and combine errors manually. For now, this works."
314314+"Each line is one concern," said Owl. "Voter checks run in sequence — existence, then duplicates. But voter and candidate run in parallel, accumulating errors from both."
286315287316---
288317···291320Owl gathered her leaves:
292321293322```typescript
294294-const formatError = (error: BallotError): string => {
295295- switch (error._tag) {
296296- case "MissingVoter":
297297- return "Ballot is missing a voter name"
298298- case "MissingCandidate":
299299- return "Ballot is missing a candidate"
300300- case "InvalidCandidate":
301301- return `"${error.name}" is not a valid candidate`
302302- case "DuplicateVoter":
303303- return `${error.voter} has already voted`
304304- }
305305-}
323323+const formatError = (error: BallotError): string =>
324324+ match(error)({
325325+ MissingVoter: () => "Ballot is missing a voter name",
326326+ MissingCandidate: () => "Ballot is missing a candidate",
327327+ InvalidCandidate: ({ name }) => `"${name}" is not a valid candidate`,
328328+ DuplicateVoter: ({ voter }) => `${voter} has already voted`,
329329+ });
306330307331// Test ballots
308332const ballots = [
309309- { voter: "Rabbit", candidate: "Deer" }, // Valid
333333+ { voter: "Rabbit", candidate: "Deer" }, // Valid
310334 { voter: undefined, candidate: "Squirrel" }, // Missing voter
311311- { voter: "Fox", candidate: undefined }, // Missing candidate
312312- { voter: "Beaver", candidate: "Wolfe" }, // Invalid candidate
313313- { voter: undefined, candidate: undefined }, // Both missing!
314314- { voter: "Rabbit", candidate: "Heron" }, // Duplicate voter
315315-]
335335+ { voter: "Fox", candidate: undefined }, // Missing candidate
336336+ { voter: "Beaver", candidate: "Wolfe" }, // Invalid candidate
337337+ { voter: undefined, candidate: undefined }, // Both missing!
338338+ { voter: "Rabbit", candidate: "Heron" }, // Duplicate voter
339339+];
316340317317-const alreadyVoted = new Set<string>()
341341+ballots.reduce<ReadonlySet<string>>((voted, { voter, candidate }, i) => {
342342+ const result = validateBallotFull(voter, candidate, voted);
318343319319-ballots.forEach(({ voter, candidate }, i) => {
320320- const result = validateBallotFull(voter, candidate, alreadyVoted)
321321-322322- matchValidation(
323323- (ballot) => {
324324- console.log(`Ballot ${i + 1}: Valid - ${ballot.voter} votes for ${ballot.candidate}`)
325325- alreadyVoted.add(ballot.voter)
344344+ return matchValidation(
345345+ (ballot: Ballot) => {
346346+ console.log(
347347+ `Ballot ${i + 1}: Valid - ${ballot.voter} votes for ${ballot.candidate}`,
348348+ );
349349+ return new Set([...voted, ballot.voter]);
350350+ },
351351+ (errors: readonly BallotError[]) => {
352352+ console.log(`Ballot ${i + 1}: Invalid`);
353353+ console.log(errors.map((e) => ` - ${formatError(e)}`).join("\n"));
354354+ return voted;
326355 },
327327- (errors) => {
328328- console.log(`Ballot ${i + 1}: Invalid`)
329329- errors.forEach((e) => console.log(` - ${formatError(e)}`))
330330- }
331331- )(result)
332332-})
356356+ )(result);
357357+}, new Set<string>());
333358```
334359335360Owl ran through the leaves. The results appeared in the dirt:
···363388364389Badger had been quiet, watching. Now he spoke. "We have valid ballots and invalid ones. The invalid ones are clearly marked. We can set them aside, ask those voters to try again with corrections."
365390366366-"And the valid ones are *truly* valid," added Rabbit. "We know they have a real voter, a real candidate, and no duplicates."
391391+"And the valid ones are _truly_ valid," added Rabbit. "We know they have a real voter, a real candidate, and no duplicates."
367392368393The sun was lower now. The pile of leaves had been sorted — valid on one side, invalid on the other with their errors carefully noted.
369394···401426402427---
403428404404-*Next: [Counting Day](./02-counting-day.md) (coming soon)*
429429+_Next: [Counting Day](/stories/forest-election/02-counting-day/) (coming soon)_
430430+431431+</div>
···11-# The Forest Election
11+---
22+title: The Forest Election
33+description: A trilogy about building a fair election system for the forest.
44+---
2536A story in three parts about building a fair election system.
47···26292730## Parts
28312929-### Part 1: [The Ballot Box Problem](./01-the-ballot-box-problem.md)
3232+### Part 1: [The Ballot Box Problem](/stories/forest-election/01-the-ballot-box-problem/)
30333134The animals gather to elect a president, but the old way — leaves in a box — leads to chaos. Blank ballots, invalid candidates, duplicate votes. Fox wants to throw out bad ballots; Rabbit insists they need to know *all* the problems. Owl introduces the Validation type.
3235···40434144---
42454343-### Part 2: [Counting Day](./02-counting-day.md)
4646+### Part 2: [Counting Day](/stories/forest-election/02-counting-day/)
44474548The ballots are validated, but counting brings new challenges. What if the count doesn't match? What if there's a tie? The animals discover that some operations must short-circuit — and `Result` is the right tool.
4649···54575558---
56595757-### Part 3: [The Announcement](./03-the-announcement.md)
6060+### Part 3: [The Announcement](/stories/forest-election/03-the-announcement/)
58615962The winner must be announced to every corner of the forest. But the messenger birds are unreliable — some might not return. The animals need effects that can be retried, raced, and cancelled.
6063···8992## See Also
90939194- [Stories overview](../) — Other available stories
9292-- [Validation and Error Accumulation](../../concepts/03-validation-and-error-accumulation.md) — Technical deep-dive
9595+- [Validation and Error Accumulation](/concepts/03-validation-and-error-accumulation/) — Technical deep-dive
···11-# Chapter 1: Why Functional TypeScript?
11+---
22+title: Why Functional TypeScript?
33+description: "Learn why exceptions break TypeScript's type safety and how purus-ts fixes it."
44+sidebar:
55+ order: 1
66+---
2738TypeScript gives you a powerful type system. You define interfaces, catch type errors at compile time, and get autocomplete in your editor. It feels like you're in control.
49···244249245250In the next chapter, you'll learn about the Effect type - purus's core abstraction for async operations. Effects take the ideas from this chapter and make them work with async code, cancellation, and dependency injection.
246251247247-[Continue to Chapter 2: Your First Effect →](./02-your-first-effect.md)
252252+[Continue to Chapter 2: Your First Effect →](/tutorial/02-your-first-effect/)
···11-# Chapter 2: Your First Effect
11+---
22+title: Your First Effect
33+description: Creating, running, and transforming effects with the Eff type.
44+sidebar:
55+ order: 2
66+---
2738In the previous chapter, we saw how `Result<T, E>` makes errors visible in the type system. But Result is synchronous - it holds a value that already exists.
49···423428424429In the next chapter, we'll dive deeper into `Result<T, E>` for synchronous error handling - the building block that makes typed errors possible.
425430426426-[Continue to Chapter 3: Typed Errors with Result →](./03-typed-errors-with-result.md)
431431+[Continue to Chapter 3: Typed Errors with Result →](/tutorial/03-typed-errors-with-result/)
···11-# Chapter 3: Typed Errors with Result
11+---
22+title: Typed Errors with Result
33+description: The Result type for synchronous error handling with Ok and Err.
44+sidebar:
55+ order: 3
66+---
2738In Chapter 1, we saw why exceptions break TypeScript's type safety. In Chapter 2, we used Effects for async operations. But what about synchronous code that can fail?
49···266271267272Result handles synchronous errors. But what about values that might not exist at all? In the next chapter, we'll explore `Option<T>` for nullable value handling.
268273269269-[Continue to Chapter 4: Optional Values with Option →](./04-optional-values-with-option.md)
274274+[Continue to Chapter 4: Optional Values with Option →](/tutorial/04-optional-values-with-option/)
···11-# Chapter 4: Optional Values with Option
11+---
22+title: Optional Values with Option
33+description: The Option type for handling nullable values with Some and None.
44+sidebar:
55+ order: 4
66+---
2738In the previous chapter, we used `Result<T, E>` for operations that can fail with an error. But sometimes there's no error - a value simply might not exist.
49···260265261266We've been using the `_tag` field to distinguish variants. In the next chapter, we'll explore pattern matching - the elegant way to handle discriminated unions exhaustively.
262267263263-[Continue to Chapter 5: Pattern Matching →](./05-pattern-matching.md)
268268+[Continue to Chapter 5: Pattern Matching →](/tutorial/05-pattern-matching/)
···11-# Chapter 5: Pattern Matching
11+---
22+title: Pattern Matching
33+description: Exhaustive type-safe matching with match(), when(), and guards.
44+sidebar:
55+ order: 5
66+---
2738Throughout this tutorial, we've used the `_tag` field to distinguish between variants. This chapter shows you the elegant way to handle these discriminated unions with exhaustive pattern matching.
49···250255251256We've been creating distinct types with `_tag`. But what about primitives? How do you prevent swapping `UserId` and `OrderId` when both are strings? The next chapter introduces branded types.
252257253253-[Continue to Chapter 6: Branded Types →](./06-branded-types.md)
258258+[Continue to Chapter 6: Branded Types →](/tutorial/06-branded-types/)
···11-# Chapter 6: Branded Types
11+---
22+title: Branded Types
33+description: Creating distinct types from primitives to prevent mix-ups at compile time.
44+sidebar:
55+ order: 6
66+---
2738We've used discriminated unions to distinguish between variants of a type. But what about primitives? A `UserId` and an `OrderId` are both strings - how do you prevent mixing them up?
49···280285281286We've covered the foundational types: Result, Option, and Branded. Now it's time to go deeper into the Effect system - how to compose async operations with typed errors and dependencies.
282287283283-[Continue to Chapter 7: The Effect System →](./07-the-effect-system.md)
288288+[Continue to Chapter 7: The Effect System →](/tutorial/07-the-effect-system/)
···11-# Chapter 7: The Effect System
11+---
22+title: The Effect System
33+description: Deep dive into Eff, lazy evaluation, and effect composition with pipe().
44+sidebar:
55+ order: 7
66+---
2738In Chapter 2, we introduced Effects as "descriptions of work." This chapter goes deeper into the `Eff<A, E, R>` type and how to compose complex async operations.
49···331336332337So far, our effects run one at a time. What about running multiple effects in parallel? Racing them? Cancelling long-running work? The next chapter covers concurrency with Fibers.
333338334334-[Continue to Chapter 8: Concurrency with Fibers →](./08-concurrency-with-fibers.md)
339339+[Continue to Chapter 8: Concurrency with Fibers →](/tutorial/08-concurrency-with-fibers/)
···11-# Chapter 8: Concurrency with Fibers
11+---
22+title: Concurrency with Fibers
33+description: Fork, join, race, and cancellation with lightweight fibers.
44+sidebar:
55+ order: 8
66+---
2738Promises give you basic concurrency with `Promise.all` and `Promise.race`. But they have limitations: `Promise.race` doesn't cancel the loser, `Promise.all` doesn't handle partial failures well, and there's no way to interrupt running work.
49···316321317322We've seen effects that require certain dependencies (the `R` type parameter). The next chapter shows how to use dependency injection to provide these requirements - without any DI framework.
318323319319-[Continue to Chapter 9: Dependency Injection →](./09-dependency-injection.md)
324324+[Continue to Chapter 9: Dependency Injection →](/tutorial/09-dependency-injection/)
···11-# Chapter 9: Dependency Injection
11+---
22+title: Dependency Injection
33+description: Environment types, provide(), and access() for testable code.
44+sidebar:
55+ order: 9
66+---
2738Testing is hard when your code has hardcoded dependencies. You end up mocking modules, setting NODE_ENV, or building elaborate test fixtures.
49···347352348353You've learned all the core concepts! The final chapter puts everything together in a complete application.
349354350350-[Continue to Chapter 10: Building a Complete App →](./10-building-a-complete-app.md)
355355+[Continue to Chapter 10: Building a Complete App →](/tutorial/10-building-a-complete-app/)
···11-# Chapter 10: Building a Complete App
11+---
22+title: Building a Complete App
33+description: Putting it all together with a CLI task manager.
44+sidebar:
55+ order: 10
66+---
2738Time to put everything together. We'll build a CLI task manager that uses every concept from this tutorial:
49
···11-# purus-ts Tutorial
11+---
22+title: Tutorial
33+description: Learn purus-ts by building a complete application step-by-step.
44+---
2536Learn purus-ts by building a complete application step-by-step.
47···3033- The "errors as values" philosophy
3134- How purus makes TypeScript's type system work for errors too
32353333-[Read Chapter 1 →](./01-why-functional-typescript.md)
3636+[Read Chapter 1 →](/tutorial/01-why-functional-typescript/)
34373538---
3639···4447- Running effects with `runPromise`, `runPromiseExit`
4548- Transforming effects with `mapEff`, `flatMap`
46494747-[Read Chapter 2 →](./02-your-first-effect.md)
5050+[Read Chapter 2 →](/tutorial/02-your-first-effect/)
48514952---
5053···5861- Transforming with `mapResult`, `chainResult`
5962- Unwrapping with `unwrapOr`, `matchResult`
60636161-[Read Chapter 3 →](./03-typed-errors-with-result.md)
6464+[Read Chapter 3 →](/tutorial/03-typed-errors-with-result/)
62656366---
6467···7275- Transforming with `mapOption`, `flatMapOption`
7376- Unwrapping with `getOrElse`, `matchOption`
74777575-[Read Chapter 4 →](./04-optional-values-with-option.md)
7878+[Read Chapter 4 →](/tutorial/04-optional-values-with-option/)
76797780---
7881···8689- Guard-based matching with `when()`
8790- Why `_tag` is the standard discriminant
88918989-[Read Chapter 5 →](./05-pattern-matching.md)
9292+[Read Chapter 5 →](/tutorial/05-pattern-matching/)
90939194---
9295···100103- Smart constructors for validation
101104- Real-world examples: IDs, emails, validated numbers
102105103103-[Read Chapter 6 →](./06-branded-types.md)
106106+[Read Chapter 6 →](/tutorial/06-branded-types/)
104107105108---
106109···114117- Building complex effects from simple ones
115118- The `pipe()` function for composition
116119117117-[Read Chapter 7 →](./07-the-effect-system.md)
120120+[Read Chapter 7 →](/tutorial/07-the-effect-system/)
118121119122---
120123···129132- Parallel execution with `all()`
130133- Cleanup functions and interruption
131134132132-[Read Chapter 8 →](./08-concurrency-with-fibers.md)
135135+[Read Chapter 8 →](/tutorial/08-concurrency-with-fibers/)
133136134137---
135138···143146- Providing dependencies with `provide()`
144147- Layered environments for testing
145148146146-[Read Chapter 9 →](./09-dependency-injection.md)
149149+[Read Chapter 9 →](/tutorial/09-dependency-injection/)
147150148151---
149152···157160- Dependency injection for testability
158161- Concurrent operations with proper cancellation
159162160160-[Read Chapter 10 →](./10-building-a-complete-app.md)
163163+[Read Chapter 10 →](/tutorial/10-building-a-complete-app/)
161164162165---
163166···165168166169After completing the tutorial:
167170168168-1. **Explore the Examples** - See `examples/` for real-world patterns
169169-2. **Read the Concepts** - Deep-dive into specific topics in `../concepts/`
171171+1. **Explore the [Examples](/examples/)** - See real-world patterns
172172+2. **Read the [Concepts](/concepts/)** - Deep-dive into specific topics
1701733. **Build Something** - The best way to learn is to use it
171171-172172-## Getting Help
173173-174174-- Check the examples in `examples/` directory
175175-- Read the source - purus is ~950 lines of well-commented TypeScript
176176-- Open an issue on GitHub for questions
···77// =============================================================================
88// Primitive Type Guards
99// =============================================================================
1010+// TS 5.5+ infers type predicates from simple narrowing expressions.
1111+// These need no explicit `: x is T` — the compiler sees `typeof x === "string"`
1212+// and infers the return type as `x is string` automatically.
10131114/** Type guard for string values */
1212-export const isString = (x: unknown): x is string => typeof x === "string"
1515+export const isString = (x: unknown) => typeof x === "string"
13161417/** Type guard for number values */
1515-export const isNumber = (x: unknown): x is number => typeof x === "number"
1818+export const isNumber = (x: unknown) => typeof x === "number"
16191720/** Type guard for boolean values */
1818-export const isBoolean = (x: unknown): x is boolean => typeof x === "boolean"
2121+export const isBoolean = (x: unknown) => typeof x === "boolean"
19222023/** Type guard for non-null object values */
2121-export const isObject = (x: unknown): x is object =>
2222- typeof x === "object" && x !== null
2424+export const isObject = (x: unknown) => typeof x === "object" && x !== null
23252426/** Type guard for array values */
2525-export const isArray = (x: unknown): x is unknown[] => Array.isArray(x)
2727+export const isArray = (x: unknown) => Array.isArray(x)
26282729// =============================================================================
2830// Number Property Guards
2931// =============================================================================
3232+// Compound guards that chain other guards (e.g. isNumber(x) && x > 0)
3333+// cannot be inferred — TS only infers single-step narrowings.
3434+// These still need explicit `: x is number`.
30353136/** Type guard for positive numbers (x > 0) */
3237export const isPositive = (x: unknown): x is number => isNumber(x) && x > 0
···4146/** Type guard for finite numbers (excludes Infinity and NaN) */
4247export const isFiniteNumber = (x: unknown): x is number =>
4348 isNumber(x) && Number.isFinite(x)
4949+5050+// =============================================================================
5151+// Nullability Guards
5252+// =============================================================================
5353+// TS 5.5+ infers these — `x !== undefined` is a single-step narrowing.
5454+5555+/** Narrows `T | undefined` to `T`. Use with `.filter(isDefined)` or in pipes. */
5656+export const isDefined = <T>(x: T | undefined) => x !== undefined
5757+5858+/** Narrows `T | null` to `T`. */
5959+export const isNotNull = <T>(x: T | null) => x !== null
6060+6161+/** Narrows `T | null | undefined` to `T`. */
6262+export const isNotNullish = <T>(x: T | null | undefined) =>
6363+ x !== null && x !== undefined
44644565// =============================================================================
4666// Guard Combinators