···33Learn purus-ts through side-by-side comparisons with vanilla TypeScript.
4455Each example includes:
66-- **README.md** - The problem and why purus helps
77-- **without-purus.ts** - How most developers write it (realistic, not strawman)
88-- **with-purus.ts** - The purus solution
66+- **README.md** - The problem and how purus helps
77+- **without-purus.ts** - Typical vanilla implementation
88+- **with-purus.ts** - Same thing with purus
991010## Examples
11111212### [http-client](./http-client/)
13131414-**Problem:** Fetching data with retry and timeout shouldn't require 50 lines of boilerplate.
1414+Fetching data with retry and timeout.
15151616**Key concepts:** `pipe`, `retry`, `timeout`, typed errors, automatic cleanup
1717···24242525### [workflow-engine](./workflow-engine/)
26262727-**Problem:** Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible.
2727+Order processing with IDs that can't be swapped and states that can't be skipped.
28282929**Key concepts:** Branded types, typestate, `match`, Result
3030···37373838### [task-queue](./task-queue/)
39394040-**Problem:** Background job processing with retries and timeouts shouldn't require a framework.
4040+Background job processing with real cancellation and dependency injection.
41414242**Key concepts:** `fork`/`join`, `catchAll`, `provide` for DI
4343···50505151## Why Side-by-Side?
52525353-The best way to understand purus-ts is to see the same problem solved both ways:
5454-5555-1. **without-purus.ts** shows realistic vanilla code - not a strawman, but how most developers actually write it
5656-2. **with-purus.ts** shows the purus solution - notice what disappears and what becomes explicit
5757-5858-Look for the `⚠️ PROBLEM:` comments in vanilla code and `✓ SOLUTION:` comments in purus code.
5353+Run both versions and compare. The vanilla code is realistic - not a strawman.
5454+Notice what changes: where the cleanup logic goes, how errors are handled,
5555+what the types tell you.
+19-22
examples/http-client/README.md
···11# HTTP Client
2233-> Fetching data with retry and timeout shouldn't require 50 lines of boilerplate.
33+Fetching data with retry and timeout.
4455## The Problem
6677-Every app fetches data from APIs. Simple, right? But production code needs:
88-- **Retry logic** for transient failures
99-- **Timeouts** so requests don't hang forever
1010-- **Proper cleanup** when requests are cancelled
1111-- **Typed errors** so you know what can go wrong
77+Every app fetches data from APIs. But production code needs retries, timeouts,
88+and cleanup. In vanilla TypeScript, this means:
1291313-In vanilla TypeScript, this turns into a mess of nested try/catch, manual AbortController management, and `catch (e: unknown)` everywhere.
1010+- Manual AbortController wiring
1111+- Remembering to clearTimeout in every code path
1212+- `catch (e: unknown)` with no idea what went wrong
14131514## Run Both Versions
1615···21202221## Without purus
23222424-See `without-purus.ts` - realistic code showing:
2525-- Manual AbortController + setTimeout coordination
2626-- Easy to forget `clearTimeout` in error paths (memory leak!)
2727-- `catch (e: unknown)` - no idea what errors can happen
2828-- Retry logic scattered with exponential backoff math
2929-- No way to cancel a request from outside
2323+See `without-purus.ts` - typical implementation with:
2424+- setTimeout + AbortController coordination
2525+- clearTimeout calls you might forget
2626+- Retry logic mixed with error handling
2727+- Errors are `unknown`
30283129## With purus
32303333-See `with-purus.ts` - same functionality with:
3434-- `pipe(fetch, retry(3), timeout(5000))` - composable one-liner
3535-- Typed `HttpError` union - compiler knows all error cases
3636-- Automatic cleanup on fiber interrupt
3737-- Clear, readable data flow
3131+See `with-purus.ts` - same thing with:
3232+- `pipe(fetch, retry(3), timeout(5000))`
3333+- Cleanup function returned from async effect
3434+- Typed HttpError union
3535+- match() for exhaustive error handling
38363937## Key Takeaways
40384141-- **Typed errors** -> No more `catch (e: unknown)` guessing games
4242-- **Automatic cleanup** -> Can't forget to clear timeouts
4343-- **Composable combinators** -> `retry` and `timeout` are just functions
4444-- **Cancellation propagates** -> Interrupt a fiber, cleanup runs automatically
3939+- Typed errors tell you what can fail
4040+- Cleanup is automatic when you return it from async()
4141+- retry() and timeout() are composable functions
+15-35
examples/http-client/with-purus.ts
···11/**
22- * HTTP Client - WITH purus
22+ * HTTP Client - with purus
33 *
44- * Same functionality as without-purus.ts, but with:
55- * - Typed errors (compiler knows all failure modes)
66- * - Composable retry/timeout (just functions!)
77- * - Automatic cleanup on cancellation
44+ * Same fetch + retry + timeout, but:
55+ * - Errors are typed (no more `unknown`)
66+ * - Cleanup happens automatically
77+ * - Retry/timeout are just composable functions
88 */
991010import {
1111- // Types
1211 type Eff,
1312 Exit,
1414-1515- // Constructors
1613 succeed,
1714 fail,
1815 async,
1919-2020- // Transformations
2116 flatMap,
2217 catchAll,
2323-2424- // Combinators
2518 timeout,
2619 retry,
2727-2828- // Composition
2920 pipe,
3021 match,
3131-3232- // Runners
3322 runPromise,
3423} from "../../src/index"
35243625// -----------------------------------------------------------------------------
3737-// SOLUTION: Typed error union - compiler knows ALL possible failures
2626+// Error types - the compiler knows exactly what can fail
3827// -----------------------------------------------------------------------------
39284029type HttpError =
···4332 | { readonly _tag: "NotFound"; readonly url: string }
4433 | { readonly _tag: "ServerError"; readonly status: number }
45344646-// SOLUTION: Smart constructors make creating errors clean
4735const HttpError = {
4836 network: (message: string): HttpError => ({ _tag: "NetworkError", message }),
4937 timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }),
···5846}
59476048// -----------------------------------------------------------------------------
6161-// SOLUTION: Fetch as an effect with automatic abort cleanup
4949+// Fetch as an effect - cleanup is handled by the runtime
6250// -----------------------------------------------------------------------------
63516452const fetchUser = (url: string): Eff<User, HttpError, unknown> =>
6553 async((resume) => {
6666- // SOLUTION: AbortController is managed automatically
6754 const controller = new AbortController()
68556956 console.log(`[Fetch] ${url}`)
···8067 }
8168 })
8269 .catch((err) => {
8383- if (err.name === "AbortError") {
8484- // SOLUTION: Interrupted - cleanup runs automatically, no resume needed
8585- return
8686- }
7070+ if (err.name === "AbortError") return // Interrupted, cleanup will run
8771 resume(Exit.fail(HttpError.network(err.message)))
8872 })
89739090- // SOLUTION: Return cleanup function - runtime calls it on interrupt!
7474+ // Return cleanup - called automatically on timeout or interruption
9175 return () => {
9276 console.log("[Cleanup] Aborting request")
9377 controller.abort()
···9579 })
96809781// -----------------------------------------------------------------------------
9898-// SOLUTION: Pattern matching - compiler forces handling ALL error cases
8282+// Error handling - try removing a case, TypeScript will complain
9983// -----------------------------------------------------------------------------
1008410185const handleError = (error: HttpError): string =>
10286 match(error)({
103103- // SOLUTION: Try removing one case - TypeScript will error!
10487 NetworkError: ({ message }) => `Network failed: ${message}`,
10588 TimeoutError: ({ ms }) => `Request timed out after ${ms}ms`,
10689 NotFound: ({ url }) => `Resource not found: ${url}`,
···10891 })
1099211093// -----------------------------------------------------------------------------
111111-// Demo - run it
9494+// Demo
11295// -----------------------------------------------------------------------------
1139611497const main = async () => {
11598 console.log("=== HTTP Client (with purus) ===\n")
11699117117- // Test 1: Successful request with retry + timeout
100100+ // Test 1: Successful request
118101 console.log("--- Test 1: Successful request ---")
119102120120- // SOLUTION: Composable one-liner! retry(2) + timeout(5000)
121103 const request1 = pipe(
122104 fetchUser("https://jsonplaceholder.typicode.com/users/1"),
123105 retry(2),
···136118 console.log(`Error: ${handleError(e as HttpError)}\n`)
137119 }
138120139139- // Test 2: Short timeout
121121+ // Test 2: Short timeout - will fail, but we recover
140122 console.log("--- Test 2: Short timeout ---")
141123142124 const request2 = pipe(
143125 fetchUser("https://jsonplaceholder.typicode.com/users/1"),
144144- timeout(1), // 1ms - will timeout
126126+ timeout(1),
145127 flatMap((result) =>
146128 result === null
147129 ? fail(HttpError.timeout(1))
148130 : succeed(result)
149131 ),
150150- // SOLUTION: catchAll provides typed error recovery
151132 catchAll((error) => {
152133 console.log(`[Caught] ${handleError(error)}`)
153134 return succeed({ id: 0, name: "Timeout Fallback", email: "" })
···157138 const fallbackUser = await runPromise(request2)
158139 console.log(`Result: ${fallbackUser.name}\n`)
159140160160- // Test 3: 404 error
141141+ // Test 3: 404 error - recover with default
161142 console.log("--- Test 3: 404 Not Found ---")
162143163144 const request3 = pipe(
164145 fetchUser("https://jsonplaceholder.typicode.com/users/99999"),
165146 retry(0),
166147 catchAll((error) => {
167167- // SOLUTION: error is HttpError, not unknown!
168148 console.log(`[Caught] ${handleError(error)}`)
169149 return succeed({ id: 0, name: "Default User", email: "" })
170150 })
+15-24
examples/http-client/without-purus.ts
···11/**
22- * HTTP Client - WITHOUT purus
22+ * HTTP Client - vanilla TypeScript
33 *
44- * This is realistic vanilla TypeScript code showing how fetch + retry + timeout
55- * is typically implemented. Notice the pain points marked with "PROBLEM:".
44+ * Typical fetch implementation with retry and timeout.
55+ * Read through and spot the gotchas - there are a few.
66 */
7788type User = {
···1212}
13131414// -----------------------------------------------------------------------------
1515-// Fetch with retry and timeout - vanilla implementation
1515+// Fetch with retry and timeout
1616// -----------------------------------------------------------------------------
17171818const fetchWithRetryAndTimeout = async (
···2727 let lastError: unknown
28282929 for (let attempt = 0; attempt <= retries; attempt++) {
3030- // PROBLEM: Manual AbortController management - easy to forget cleanup
3130 const controller = new AbortController()
3231 let timeoutId: ReturnType<typeof setTimeout> | undefined
33323433 try {
3535- // PROBLEM: setTimeout + AbortController coordination is tricky
3434+ // Wire up the timeout to abort the request
3635 timeoutId = setTimeout(() => controller.abort(), timeoutMs)
37363837 console.log(`[Attempt ${attempt + 1}/${retries + 1}] Fetching ${url}...`)
39384039 const response = await fetch(url, { signal: controller.signal })
41404242- // PROBLEM: Must remember to clear timeout in success path
4343- clearTimeout(timeoutId)
4141+ clearTimeout(timeoutId) // Don't forget this!
44424543 if (!response.ok) {
4646- // PROBLEM: What if we forget clearTimeout here? Memory leak!
4444+ // Whoops - did you remember clearTimeout before throwing?
4745 throw new Error(`HTTP ${response.status}: ${response.statusText}`)
4846 }
4947···5250 return data as User
53515452 } catch (e: unknown) {
5555- // PROBLEM: clearTimeout is easy to forget in catch block!
5353+ // Need to clean up here too - easy to miss
5654 if (timeoutId) clearTimeout(timeoutId)
57555858- // PROBLEM: e is `unknown` - we don't know what errors can occur
5959- lastError = e
5656+ lastError = e // e is unknown - we'll deal with it later... somehow
60576158 const isTimeout = e instanceof Error && e.name === "AbortError"
6259 const message = e instanceof Error ? e.message : "Unknown error"
63606461 console.log(`[Attempt ${attempt + 1}] Failed: ${isTimeout ? "Timeout" : message}`)
65626666- // PROBLEM: Retry logic is scattered and mixed with error handling
6763 if (attempt < retries) {
6868- const delay = retryDelayMs * Math.pow(2, attempt) // Exponential backoff
6464+ const delay = retryDelayMs * Math.pow(2, attempt)
6965 console.log(`[Retry] Waiting ${delay}ms before retry...`)
7066 await new Promise(resolve => setTimeout(resolve, delay))
7167 }
7268 }
7369 }
74707575- // PROBLEM: We throw `unknown` - caller has no idea what to catch
7171+ // Caller gets unknown - good luck figuring out what went wrong
7672 throw lastError
7773}
78747975// -----------------------------------------------------------------------------
8080-// Demo - run it
7676+// Demo
8177// -----------------------------------------------------------------------------
82788379const main = async () => {
8480 console.log("=== HTTP Client (without purus) ===\n")
85818682 try {
8787- // Test 1: Successful request
8883 console.log("--- Test 1: Successful request ---")
8984 const user = await fetchWithRetryAndTimeout(
9085 "https://jsonplaceholder.typicode.com/users/1",
···9388 console.log(`Got user: ${user.name} (${user.email})\n`)
94899590 } catch (e: unknown) {
9696- // PROBLEM: We caught `unknown` - what is it? NetworkError? Timeout? 404?
9797- // TypeScript can't help us here. We have to guess and check manually.
9191+ // What did we catch? Network error? Timeout? 404? 500?
9292+ // TypeScript shrugs. We have to guess.
9893 console.error("Failed:", e instanceof Error ? e.message : e)
9994 }
1009510196 try {
102102- // Test 2: Request with short timeout (will likely timeout)
10397 console.log("--- Test 2: Short timeout ---")
10498 const user = await fetchWithRetryAndTimeout(
10599 "https://jsonplaceholder.typicode.com/users/1",
106106- { retries: 1, timeoutMs: 1 } // 1ms timeout - will fail
100100+ { retries: 1, timeoutMs: 1 }
107101 )
108102 console.log(`Got user: ${user.name}\n`)
109103110104 } catch (e: unknown) {
111111- // PROBLEM: Same issue - e is unknown, no compiler help
112105 console.log("Expected timeout:", e instanceof Error ? e.message : e)
113106 console.log()
114107 }
115108116109 try {
117117- // Test 3: 404 error
118110 console.log("--- Test 3: 404 Not Found ---")
119111 const user = await fetchWithRetryAndTimeout(
120112 "https://jsonplaceholder.typicode.com/users/99999",
···123115 console.log(`Got user: ${user.name}\n`)
124116125117 } catch (e: unknown) {
126126- // PROBLEM: Is this a 404? 500? Network error? We don't know at compile time!
127118 console.log("Expected 404:", e instanceof Error ? e.message : e)
128119 console.log()
129120 }
+16-22
examples/task-queue/README.md
···11# Task Queue
2233-> Background job processing with retries and timeouts shouldn't require a framework.
33+Background job processing with real cancellation and easy testing.
4455## The Problem
6677-Background jobs are everywhere: sending emails, processing images, syncing data. Each job needs:
88-- **Retry logic** for transient failures
99-- **Timeouts** so jobs don't run forever
1010-- **Error handling** with context preserved
1111-- **Testability** - easy to mock dependencies
1212-1313-In vanilla TypeScript, you end up with manual Promise tracking, scattered try/catch blocks, and code that's impossible to test without real dependencies.
77+Background jobs need retries, timeouts, and error handling. In vanilla TypeScript:
88+- Promise.race doesn't actually cancel the losing promise
99+- Dependencies are hardcoded, making tests awkward
1010+- Error context gets lost after retries
14111512## Run Both Versions
1613···21182219## Without purus
23202424-See `without-purus.ts` - realistic code showing:
2525-- Manual Promise tracking for workers
2626-- `Promise.race` for timeout (doesn't cancel the job!)
2727-- Lost error context after retries fail
2828-- Hard to test - dependencies are hardcoded
2121+See `without-purus.ts`:
2222+- Promise.race for timeout (but the job keeps running!)
2323+- Hardcoded logger - can't mock without a DI framework
2424+- Errors are `unknown` after retry exhaustion
29253026## With purus
31273232-See `with-purus.ts` - same functionality with:
3333-- `fork`/`join` for fiber-based workers
3434-- `timeout(30000)` actually cancels the job
3535-- `catchAll` preserves error context
3636-- `provide(mockEnv)` makes testing trivial
2828+See `with-purus.ts`:
2929+- timeout() returns cleanup function - job actually stops
3030+- provide(env) injects dependencies - swap for tests
3131+- Typed errors preserved through the pipeline
37323833## Key Takeaways
39344040-- **Fibers > Promises** → Real cancellation, not just ignoring results
4141-- **Composable timeouts** → `timeout()` is just another combinator
4242-- **Error context preserved** → `catchAll` sees the original error
4343-- **Dependency injection** → `provide()` makes testing trivial
3535+- Return a cleanup function from async() for real cancellation
3636+- provide() makes testing easy without frameworks
3737+- catchAll() preserves error context
+14-41
examples/task-queue/with-purus.ts
···11/**
22- * Task Queue - WITH purus
22+ * Task Queue - with purus
33 *
44- * Same functionality as without-purus.ts, but with:
55- * - Fiber-based execution with real cancellation
66- * - Composable retry/timeout combinators
77- * - Error context preserved through the pipeline
88- * - Dependency injection for easy testing
44+ * Same job processor, but:
55+ * - timeout() actually cancels the job
66+ * - Dependencies are injected (easy to test)
77+ * - Errors are typed, not unknown
98 */
1091110import {
1212- // Types
1311 type Eff,
1412 Exit,
1515-1616- // Constructors
1713 succeed,
1814 fail,
1915 async,
2020-2121- // Transformations
2216 flatMap,
2317 mapEff,
2418 catchAll,
2525-2626- // Environment
2719 accessEff,
2820 provide,
2929-3030- // Combinators
3121 timeout,
3222 retry,
3323 allSequential,
3434-3535- // Composition
3624 pipe,
3737-3838- // Runners
3925 runPromise,
4026} from "../../src/index"
41274228// -----------------------------------------------------------------------------
4343-// Job Types
2929+// Job types
4430// -----------------------------------------------------------------------------
45314632type Job = {
···4935 payload: Record<string, unknown>
5036}
51375252-// SOLUTION: Typed error union - compiler knows all failure modes
5338type JobError =
5439 | { readonly _tag: "TransientError"; readonly jobId: string; readonly message: string }
5540 | { readonly _tag: "TimeoutError"; readonly jobId: string; readonly ms: number }
···6146 ({ _tag: "TimeoutError", jobId, ms }),
6247}
63486464-// SOLUTION: Environment type for dependency injection
4949+// Environment - swap this out in tests
6550type QueueEnv = {
6651 logger: {
6752 info: (msg: string) => void
···7055}
71567257// -----------------------------------------------------------------------------
7373-// Job Execution
5858+// Job execution - with real cancellation
7459// -----------------------------------------------------------------------------
75607676-// SOLUTION: executeJob returns an Eff with cleanup function for real cancellation
7761const executeJob = (job: Job): Eff<void, JobError, QueueEnv> =>
7862 async((resume) => {
7979- // SOLUTION: Cancellation token - cleanup function can abort this
8063 let cancelled = false
8164 const delay = Math.random() * 200 + 50
82658366 const timeoutId = setTimeout(() => {
8484- if (cancelled) return // SOLUTION: Check cancellation before resuming
6767+ if (cancelled) return // Already cancelled, don't resume
85688686- // Simulate 30% failure rate
8769 if (Math.random() < 0.3) {
8870 resume(Exit.fail(JobError.transient(job.id, "transient error")))
8971 } else {
···9173 }
9274 }, delay)
93759494- // SOLUTION: Return cleanup function - runtime calls this on timeout/interrupt
7676+ // Return cleanup - called on timeout or interrupt
9577 return () => {
9678 cancelled = true
9779 clearTimeout(timeoutId)
···9981 })
1008210183// -----------------------------------------------------------------------------
102102-// Job Processing Pipeline
8484+// Job pipeline
10385// -----------------------------------------------------------------------------
1048610587const TIMEOUT_MS = 5000
10688const MAX_RETRIES = 3
10789108108-// SOLUTION: Composable pipeline - each combinator is just a function
10990const processJob = (job: Job): Eff<void, never, QueueEnv> =>
11091 pipe(
111111- // SOLUTION: accessEff to get logger from environment
11292 accessEff((env: QueueEnv) =>
11393 pipe(
11494 succeed(undefined),
···11898 })
11999 )
120100 ),
121121- // SOLUTION: retry(3) - composable, not scattered through code
122101 retry(MAX_RETRIES),
123123- // SOLUTION: timeout actually cancels the job via cleanup function!
124102 timeout(TIMEOUT_MS),
125103 flatMap((result) =>
126104 result === null
127105 ? fail(JobError.timeout(job.id, TIMEOUT_MS))
128106 : succeed(result)
129107 ),
130130- // SOLUTION: catchAll preserves typed error context
131108 catchAll((error: JobError) =>
132109 accessEff((env: QueueEnv) => {
133133- // SOLUTION: error is JobError, not unknown - full type information!
134110 const msg = error._tag === "TimeoutError"
135111 ? `Job ${error.jobId} timed out after ${error.ms}ms`
136112 : `Job ${error.jobId} failed: ${error.message}`
···138114 return succeed(undefined)
139115 })
140116 ),
141141- // SOLUTION: Log success using environment
142117 flatMap(() =>
143118 accessEff((env: QueueEnv) => {
144119 env.logger.info(`Job ${job.id} completed`)
···148123 )
149124150125// -----------------------------------------------------------------------------
151151-// Queue Processing
126126+// Queue processing
152127// -----------------------------------------------------------------------------
153128154129const processQueue = (jobs: Job[]): Eff<void, never, QueueEnv> =>
···161136// Environments
162137// -----------------------------------------------------------------------------
163138164164-// Production environment
165139const prodEnv: QueueEnv = {
166140 logger: {
167141 info: (msg) => console.log(`[INFO] ${msg}`),
···169143 },
170144}
171145172172-// SOLUTION: Test environment - just provide a different object!
146146+// For tests - just swap provide(prodEnv) with provide(testEnv)
173147const _testEnv: QueueEnv = {
174148 logger: {
175175- info: () => {}, // Silent for tests
149149+ info: () => {},
176150 error: () => {},
177151 },
178152}
···190164 { id: "job-3", type: "sync", payload: { source: "db", target: "cache" } },
191165 ]
192166193193- // SOLUTION: provide() injects dependencies - swap prodEnv for _testEnv in tests!
194167 const program = pipe(
195168 processQueue(jobs),
196169 provide(prodEnv)
+13-20
examples/task-queue/without-purus.ts
···11/**
22- * Task Queue - WITHOUT purus
22+ * Task Queue - vanilla TypeScript
33 *
44- * This is realistic vanilla TypeScript code for background job processing.
55- * Notice the pain points marked with "PROBLEM:".
44+ * A simple background job processor. Note how Promise.race
55+ * doesn't actually cancel the losing promise.
66 */
7788// -----------------------------------------------------------------------------
99-// Job Types
99+// Job types
1010// -----------------------------------------------------------------------------
11111212type Job = {
···1515 payload: Record<string, unknown>
1616}
17171818-// PROBLEM: Hardcoded logger - can't mock for testing without dependency injection framework
1818+// Hardcoded logger - awkward to mock in tests
1919const logger = {
2020 info: (msg: string) => console.log(`[INFO] ${msg}`),
2121 error: (msg: string) => console.log(`[ERROR] ${msg}`),
2222}
23232424// -----------------------------------------------------------------------------
2525-// Job Execution with Timeout
2525+// Job execution
2626// -----------------------------------------------------------------------------
27272828const executeJob = async (job: Job): Promise<void> => {
2929- // Simulate job processing
3029 const delay = Math.random() * 200 + 50
3130 await new Promise(resolve => setTimeout(resolve, delay))
32313333- // Simulate 30% failure rate
3232+ // 30% chance of failure
3433 if (Math.random() < 0.3) {
3534 throw new Error(`Job ${job.id} failed: transient error`)
3635 }
···3938}
40394140const executeWithTimeout = async (job: Job, timeoutMs: number): Promise<void> => {
4242- // PROBLEM: Promise.race doesn't cancel the losing promise!
4343- // The job keeps running even after timeout - we just ignore the result
4141+ // Promise.race returns when one settles, but the loser keeps running!
4242+ // We're just ignoring the result, not actually cancelling the job.
4443 const result = await Promise.race([
4544 executeJob(job),
4645 new Promise<never>((_, reject) =>
···5150}
52515352// -----------------------------------------------------------------------------
5454-// Retry Logic
5353+// Retry logic
5554// -----------------------------------------------------------------------------
56555756const executeWithRetry = async (
···6564 try {
6665 logger.info(`[Attempt ${attempt}/${maxRetries}] Processing job ${job.id}`)
6766 await executeWithTimeout(job, timeoutMs)
6868- return // Success!
6767+ return
6968 } catch (e) {
7070- // PROBLEM: We lose the original error context after multiple retries
7171- // Only the last error is preserved
7269 lastError = e
7370 logger.error(`Attempt ${attempt} failed: ${e instanceof Error ? e.message : e}`)
7471 }
7572 }
76737777- // PROBLEM: Throwing unknown - caller has no type information about the error
7878- throw lastError
7474+ throw lastError // Caller gets unknown
7975}
80768177// -----------------------------------------------------------------------------
8282-// Queue Processing
7878+// Queue processing
8379// -----------------------------------------------------------------------------
84808581const processQueue = async (jobs: Job[]): Promise<void> => {
8686- // PROBLEM: Manual promise tracking - easy to mess up
8782 const results: Array<{ job: Job; success: boolean; error?: unknown }> = []
88838984 for (const job of jobs) {
···9186 await executeWithRetry(job, 3, 5000)
9287 results.push({ job, success: true })
9388 } catch (e) {
9494- // PROBLEM: e is unknown - what kind of error was it? Timeout? Job failure?
9589 results.push({ job, success: false, error: e })
9690 }
9791 }
98929999- // Summary
10093 const succeeded = results.filter(r => r.success).length
10194 const failed = results.filter(r => !r.success).length
10295 logger.info(`Queue complete: ${succeeded} succeeded, ${failed} failed`)
+18-22
examples/workflow-engine/README.md
···11# Workflow Engine
2233-> Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible.
33+Order processing where IDs can't be swapped and invalid transitions don't compile.
4455## The Problem
6677-Business logic is full of:
88-- **IDs that look alike** - OrderId, CustomerId, ProductId are all strings
99-- **State transitions** - Can't ship an unpaid order, can't refund a draft
1010-- **Error cases** - OutOfStock, PaymentDeclined, InvalidAddress...
77+Business logic has:
88+- IDs that look alike (OrderId, CustomerId - both strings)
99+- State transitions (can't ship before paying)
1010+- Error cases you might forget to handle
11111212-In vanilla TypeScript:
1313-- Swapping `orderId` and `customerId` compiles fine, fails at runtime
1414-- State checks are scattered `if (order.status === 'paid')` everywhere
1515-- `catch (e: unknown)` means you're guessing what errors can happen
1212+In vanilla TypeScript, swapping `orderId` and `customerId` compiles fine.
1313+Shipping an unpaid order throws at runtime.
16141715## Run Both Versions
1816···2422## Without purus
25232624See `without-purus.ts`:
2727-- All IDs are `string` - easy to swap arguments
2828-- Runtime status checks before every operation
2929-- `switch` statements with forgotten cases
3030-- Errors as thrown exceptions with unknown shape
2525+- Type aliases (`type OrderId = string`) don't prevent mixups
2626+- Runtime checks (`if (order.status !== 'paid')`)
2727+- String matching in error handling
2828+- There's a bug in lookupOrder - can you spot it?
31293230## With purus
33313434-See `with-purus.ts` - same functionality with:
3535-- Branded types: `OrderId` and `CustomerId` can't be mixed
3636-- Typestate: `ship(order: PaidOrder)` - can't ship unpaid at compile time
3737-- `match()` forces handling ALL error cases
3838-- Result type makes errors explicit values
3232+See `with-purus.ts`:
3333+- Branded types: `OrderId` and `CustomerId` are incompatible
3434+- Typestate: `shipOrder(order: PaidOrder)` - can't pass a draft
3535+- match() forces you to handle every error case
39364037## Key Takeaways
41384242-- **Branded types** -> Can't pass OrderId where CustomerId expected
4343-- **Typestate** -> Invalid transitions are compile errors, not runtime checks
4444-- **Exhaustive matching** -> Forget an error case = compile error
4545-- **Errors as values** -> No more `catch (e: unknown)` guessing
3939+- Branded types catch ID mixups at compile time
4040+- Typestate turns runtime checks into type errors
4141+- Exhaustive matching means you can't forget error cases
+25-37
examples/workflow-engine/with-purus.ts
···11/**
22- * Workflow Engine - WITH purus
22+ * Workflow Engine - with purus
33 *
44- * Same functionality as without-purus.ts, but with:
55- * - Branded types: OrderId and CustomerId can't be mixed
66- * - Typestate: ship(order: PaidOrder) - can't ship unpaid at compile time
77- * - Exhaustive matching: forget an error case = compile error
88- * - Errors as values: no more catch (e: unknown) guessing
44+ * Same order flow, but:
55+ * - Branded types: can't swap OrderId and CustomerId
66+ * - Typestate: shipOrder only accepts PaidOrder
77+ * - match(): forget an error case = compile error
98 */
1091110import {
1212- // Types
1311 type Branded,
1412 type Entity,
1513 type Result,
1616-1717- // Constructors
1814 brand,
1915 entity,
2016 transition,
2117 ok,
2218 err,
2323-2424- // Pattern matching
2519 match,
2620 matchResult,
2721} from "../../src/index"
28222923// -----------------------------------------------------------------------------
3030-// SOLUTION: Branded types - OrderId and CustomerId are distinct!
2424+// Branded types - OrderId and CustomerId can't be mixed up
3125// -----------------------------------------------------------------------------
32263327type OrderId = Branded<string, "OrderId">
···38323933type ProductId = Branded<string, "ProductId">
4034const ProductId = (s: string): ProductId => brand(s)
3535+3636+// Try passing a CustomerId where OrderId is expected - TypeScript will stop you.
41374238// -----------------------------------------------------------------------------
4343-// SOLUTION: Typestate - Order states are separate types!
3939+// Typestate - each order state is a different type
4440// -----------------------------------------------------------------------------
45414642type OrderData = {
···5046 readonly quantity: number
5147}
52485353-// SOLUTION: Each state is a distinct type - not a string field!
5449type DraftOrder = Entity<OrderData, "Draft">
5550type PaidOrder = Entity<OrderData, "Paid">
5651type ShippedOrder = Entity<OrderData, "Shipped">
57525858-// SOLUTION: Typed errors - compiler knows all failure modes
5353+// No status field to check - the type IS the status.
5454+5955type OrderError =
6056 | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string }
6157 | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId }
···7167}
72687369// -----------------------------------------------------------------------------
7474-// SOLUTION: State transitions are type-safe functions
7070+// State transitions - the types enforce valid transitions
7571// -----------------------------------------------------------------------------
76727777-// SOLUTION: Only accepts DraftOrder - can't pass PaidOrder or ShippedOrder!
7373+// Only accepts DraftOrder. Try passing a ShippedOrder - won't compile.
7874const payOrder = (order: DraftOrder): Result<PaidOrder, OrderError> => {
7975 console.log(`[Payment] Processing payment for order ${order.id}`)
8080- // Transition from Draft to Paid
8176 const toPaid = transition<OrderData, "Draft", "Paid">()
8277 return ok(toPaid(order))
8378}
84798585-// SOLUTION: Only accepts PaidOrder - compile error if you try to ship a draft!
8080+// Only accepts PaidOrder. No runtime check needed.
8681const shipOrder = (order: PaidOrder): ShippedOrder => {
8782 console.log(`[Shipping] Shipping order ${order.id}`)
8888- // Transition from Paid to Shipped
8983 const toShipped = transition<OrderData, "Paid", "Shipped">()
9084 return toShipped(order)
9185}
92869393-// SOLUTION: Arguments have distinct types - swapping them is a compile error!
8787+// Branded types prevent the bug from the vanilla version
9488const lookupOrder = (
9595- orderId: OrderId, // <-- Branded type
9696- customerId: CustomerId // <-- Different branded type
8989+ orderId: OrderId,
9090+ customerId: CustomerId
9791): Result<DraftOrder, OrderError> => {
9892 if (orderId === "order-1" && customerId === "cust-1") {
9993 const data: OrderData = {
···107101 return err(OrderError.orderNotFound(orderId))
108102}
109103110110-// SOLUTION: This would be a compile error!
111111-// function buggyLookup() {
104104+// This won't compile - TypeScript catches the swapped arguments:
105105+// const buggyLookup = () => {
112106// const customerId = CustomerId("cust-1")
113107// const orderId = OrderId("order-1")
114114-// // ERROR: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'
115115-// return lookupOrder(customerId, orderId)
108108+// return lookupOrder(customerId, orderId) // Error!
116109// }
117110118111// -----------------------------------------------------------------------------
119119-// SOLUTION: Exhaustive pattern matching - can't forget error cases
112112+// Error handling - exhaustive matching
120113// -----------------------------------------------------------------------------
121114122122-const formatError = (error: OrderError): string => {
123123- // SOLUTION: If you add a new error type and forget to handle it, compiler errors!
124124- return match(error)({
115115+const formatError = (error: OrderError): string =>
116116+ // Try removing a case - TypeScript will complain
117117+ match(error)({
125118 InvalidTransition: ({ from, to }) =>
126119 `Cannot transition order from ${from} to ${to}`,
127120 OrderNotFound: ({ orderId }) =>
···129122 PaymentDeclined: ({ reason }) =>
130123 `Payment declined: ${reason}`,
131124 })
132132-}
133125134126// -----------------------------------------------------------------------------
135135-// Demo - run it
127127+// Demo
136128// -----------------------------------------------------------------------------
137129138130const main = () => {
139131 console.log("=== Workflow Engine (with purus) ===\n")
140132141141- // Test 1: Successful flow
142133 console.log("--- Test 1: Successful order flow ---")
143134144135 const orderId = OrderId("order-1")
···151142 const payResult = payOrder(draft)
152143 matchResult<PaidOrder, OrderError, void>(
153144 (paid) => {
154154- // SOLUTION: shipOrder only accepts PaidOrder - type-safe!
155145 const shipped = shipOrder(paid)
156146 console.log(`Order ${shipped.id} shipped successfully!\n`)
157147 },
···161151 (error) => console.log(`Error: ${formatError(error)}\n`)
162152 )(result)
163153164164- // Test 2: Type safety demonstration
165154 console.log("--- Test 2: Type safety (compile-time protection) ---")
166155 console.log("The following would be compile errors in purus:")
167156 console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder")
168157 console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId")
169158 console.log("These bugs are caught at compile time, not runtime!\n")
170159171171- // Test 3: Order not found
172160 console.log("--- Test 3: Order not found ---")
173161 const notFoundResult = lookupOrder(OrderId("invalid"), customerId)
174162 matchResult<DraftOrder, OrderError, void>(
+14-24
examples/workflow-engine/without-purus.ts
···11/**
22- * Workflow Engine - WITHOUT purus
22+ * Workflow Engine - vanilla TypeScript
33 *
44- * This is realistic vanilla TypeScript code showing how order processing
55- * is typically implemented. Notice the pain points marked with "PROBLEM:".
44+ * A simple order processing flow. Watch for the bug in lookupOrder -
55+ * it compiles fine but fails at runtime.
66 */
7788// -----------------------------------------------------------------------------
99-// IDs - just type aliases to string
99+// IDs - type aliases don't prevent mixups
1010// -----------------------------------------------------------------------------
11111212-// PROBLEM: These are all just strings at runtime - easy to mix up!
1312type OrderId = string
1413type CustomerId = string
1514type ProductId = string
16151616+// All three are just strings, so swapping them won't cause a compile error.
1717+1718// -----------------------------------------------------------------------------
1819// Order with status field
1920// -----------------------------------------------------------------------------
···2930}
30313132// -----------------------------------------------------------------------------
3232-// Error handling via thrown exceptions
3333+// State transitions with runtime checks
3334// -----------------------------------------------------------------------------
34353536const payOrder = (order: Order): Order => {
3636- // PROBLEM: Runtime check that could be forgotten
3737 if (order.status !== "draft") {
3838 throw new Error(`Cannot pay order in ${order.status} status`)
3939 }
···4242}
43434444const shipOrder = (order: Order): Order => {
4545- // PROBLEM: Another runtime check - easy to forget or get wrong
4645 if (order.status !== "paid") {
4746 throw new Error(`Cannot ship order in ${order.status} status`)
4847 }
···5049 return { ...order, status: "shipped" }
5150}
52515353-// PROBLEM: Arguments are all strings - swapping them compiles fine!
5252+// Both arguments are strings - can you spot the bug below?
5453const lookupOrder = (orderId: OrderId, customerId: CustomerId): Order | null => {
5555- // Simulate database lookup
5654 if (orderId === "order-1" && customerId === "cust-1") {
5755 return {
5856 id: orderId,
···6563 return null
6664}
67656868-// PROBLEM: This has a bug - arguments are swapped! But it compiles fine.
6666+// This compiles fine, but the arguments are backwards!
6967const buggyLookup = () => {
7068 const customerId: CustomerId = "cust-1"
7169 const orderId: OrderId = "order-1"
72707373- // PROBLEM: We swapped the arguments - this compiles but returns null!
7474- const order = lookupOrder(customerId, orderId) // <-- BUG: swapped!
7171+ const order = lookupOrder(customerId, orderId) // Oops - swapped!
7572 return order
7673}
77747875// -----------------------------------------------------------------------------
7979-// Error formatting with switch
7676+// Error handling
8077// -----------------------------------------------------------------------------
81788279const formatError = (error: unknown): string => {
8380 if (error instanceof Error) {
8481 const message = error.message
85828686- // PROBLEM: String matching is fragile and not exhaustive
8383+ // String matching is fragile - what if the message changes?
8784 if (message.includes("Cannot pay")) {
8885 return "Payment failed: Order is not in draft status"
8986 }
9087 if (message.includes("Cannot ship")) {
9188 return "Shipping failed: Order is not paid"
9289 }
9393- // PROBLEM: Easy to forget cases - no compiler warning!
9490 return `Unknown error: ${message}`
9591 }
9692 return "Unknown error"
9793}
98949995// -----------------------------------------------------------------------------
100100-// Demo - run it
9696+// Demo
10197// -----------------------------------------------------------------------------
1029810399const main = () => {
104100 console.log("=== Workflow Engine (without purus) ===\n")
105101106106- // Test 1: Successful flow
107102 console.log("--- Test 1: Successful order flow ---")
108103 try {
109104 const order: Order = {
···118113 const shipped = shipOrder(paid)
119114 console.log(`Order ${shipped.id} shipped successfully!\n`)
120115 } catch (e: unknown) {
121121- // PROBLEM: e is unknown - we have to guess what it might be
122116 console.log(formatError(e))
123117 }
124118125125- // Test 2: Invalid transition
126119 console.log("--- Test 2: Invalid state transition ---")
127120 try {
128121 const order: Order = {
···133126 status: "draft",
134127 }
135128136136- // PROBLEM: Trying to ship a draft order - fails at runtime, not compile time!
129129+ // This throws at runtime - would be nice to catch at compile time
137130 const shipped = shipOrder(order)
138131 console.log(`Shipped: ${shipped.id}\n`)
139132 } catch (e: unknown) {
140133 console.log(`Error: ${formatError(e)}\n`)
141134 }
142135143143- // Test 3: Swapped arguments bug
144136 console.log("--- Test 3: Swapped arguments bug ---")
145137 const order = buggyLookup()
146138 if (order === null) {
147147- // PROBLEM: This fails at runtime because we swapped orderId/customerId
148148- // TypeScript didn't catch the bug because both are just `string`
149139 console.log("Order not found (bug: arguments were swapped!)\n")
150140 }
151141