An educational pure functional programming library in TypeScript
2
fork

Configure Feed

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

Rewrite example comments and docs for clarity

+158 -259
+9 -12
examples/README.md
··· 3 3 Learn purus-ts through side-by-side comparisons with vanilla TypeScript. 4 4 5 5 Each example includes: 6 - - **README.md** - The problem and why purus helps 7 - - **without-purus.ts** - How most developers write it (realistic, not strawman) 8 - - **with-purus.ts** - The purus solution 6 + - **README.md** - The problem and how purus helps 7 + - **without-purus.ts** - Typical vanilla implementation 8 + - **with-purus.ts** - Same thing with purus 9 9 10 10 ## Examples 11 11 12 12 ### [http-client](./http-client/) 13 13 14 - **Problem:** Fetching data with retry and timeout shouldn't require 50 lines of boilerplate. 14 + Fetching data with retry and timeout. 15 15 16 16 **Key concepts:** `pipe`, `retry`, `timeout`, typed errors, automatic cleanup 17 17 ··· 24 24 25 25 ### [workflow-engine](./workflow-engine/) 26 26 27 - **Problem:** Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible. 27 + Order processing with IDs that can't be swapped and states that can't be skipped. 28 28 29 29 **Key concepts:** Branded types, typestate, `match`, Result 30 30 ··· 37 37 38 38 ### [task-queue](./task-queue/) 39 39 40 - **Problem:** Background job processing with retries and timeouts shouldn't require a framework. 40 + Background job processing with real cancellation and dependency injection. 41 41 42 42 **Key concepts:** `fork`/`join`, `catchAll`, `provide` for DI 43 43 ··· 50 50 51 51 ## Why Side-by-Side? 52 52 53 - The best way to understand purus-ts is to see the same problem solved both ways: 54 - 55 - 1. **without-purus.ts** shows realistic vanilla code - not a strawman, but how most developers actually write it 56 - 2. **with-purus.ts** shows the purus solution - notice what disappears and what becomes explicit 57 - 58 - Look for the `⚠️ PROBLEM:` comments in vanilla code and `✓ SOLUTION:` comments in purus code. 53 + Run both versions and compare. The vanilla code is realistic - not a strawman. 54 + Notice what changes: where the cleanup logic goes, how errors are handled, 55 + what the types tell you.
+19 -22
examples/http-client/README.md
··· 1 1 # HTTP Client 2 2 3 - > Fetching data with retry and timeout shouldn't require 50 lines of boilerplate. 3 + Fetching data with retry and timeout. 4 4 5 5 ## The Problem 6 6 7 - Every app fetches data from APIs. Simple, right? But production code needs: 8 - - **Retry logic** for transient failures 9 - - **Timeouts** so requests don't hang forever 10 - - **Proper cleanup** when requests are cancelled 11 - - **Typed errors** so you know what can go wrong 7 + Every app fetches data from APIs. But production code needs retries, timeouts, 8 + and cleanup. In vanilla TypeScript, this means: 12 9 13 - In vanilla TypeScript, this turns into a mess of nested try/catch, manual AbortController management, and `catch (e: unknown)` everywhere. 10 + - Manual AbortController wiring 11 + - Remembering to clearTimeout in every code path 12 + - `catch (e: unknown)` with no idea what went wrong 14 13 15 14 ## Run Both Versions 16 15 ··· 21 20 22 21 ## Without purus 23 22 24 - See `without-purus.ts` - realistic code showing: 25 - - Manual AbortController + setTimeout coordination 26 - - Easy to forget `clearTimeout` in error paths (memory leak!) 27 - - `catch (e: unknown)` - no idea what errors can happen 28 - - Retry logic scattered with exponential backoff math 29 - - No way to cancel a request from outside 23 + See `without-purus.ts` - typical implementation with: 24 + - setTimeout + AbortController coordination 25 + - clearTimeout calls you might forget 26 + - Retry logic mixed with error handling 27 + - Errors are `unknown` 30 28 31 29 ## With purus 32 30 33 - See `with-purus.ts` - same functionality with: 34 - - `pipe(fetch, retry(3), timeout(5000))` - composable one-liner 35 - - Typed `HttpError` union - compiler knows all error cases 36 - - Automatic cleanup on fiber interrupt 37 - - Clear, readable data flow 31 + See `with-purus.ts` - same thing with: 32 + - `pipe(fetch, retry(3), timeout(5000))` 33 + - Cleanup function returned from async effect 34 + - Typed HttpError union 35 + - match() for exhaustive error handling 38 36 39 37 ## Key Takeaways 40 38 41 - - **Typed errors** -> No more `catch (e: unknown)` guessing games 42 - - **Automatic cleanup** -> Can't forget to clear timeouts 43 - - **Composable combinators** -> `retry` and `timeout` are just functions 44 - - **Cancellation propagates** -> Interrupt a fiber, cleanup runs automatically 39 + - Typed errors tell you what can fail 40 + - Cleanup is automatic when you return it from async() 41 + - retry() and timeout() are composable functions
+15 -35
examples/http-client/with-purus.ts
··· 1 1 /** 2 - * HTTP Client - WITH purus 2 + * HTTP Client - with purus 3 3 * 4 - * Same functionality as without-purus.ts, but with: 5 - * - Typed errors (compiler knows all failure modes) 6 - * - Composable retry/timeout (just functions!) 7 - * - Automatic cleanup on cancellation 4 + * Same fetch + retry + timeout, but: 5 + * - Errors are typed (no more `unknown`) 6 + * - Cleanup happens automatically 7 + * - Retry/timeout are just composable functions 8 8 */ 9 9 10 10 import { 11 - // Types 12 11 type Eff, 13 12 Exit, 14 - 15 - // Constructors 16 13 succeed, 17 14 fail, 18 15 async, 19 - 20 - // Transformations 21 16 flatMap, 22 17 catchAll, 23 - 24 - // Combinators 25 18 timeout, 26 19 retry, 27 - 28 - // Composition 29 20 pipe, 30 21 match, 31 - 32 - // Runners 33 22 runPromise, 34 23 } from "../../src/index" 35 24 36 25 // ----------------------------------------------------------------------------- 37 - // SOLUTION: Typed error union - compiler knows ALL possible failures 26 + // Error types - the compiler knows exactly what can fail 38 27 // ----------------------------------------------------------------------------- 39 28 40 29 type HttpError = ··· 43 32 | { readonly _tag: "NotFound"; readonly url: string } 44 33 | { readonly _tag: "ServerError"; readonly status: number } 45 34 46 - // SOLUTION: Smart constructors make creating errors clean 47 35 const HttpError = { 48 36 network: (message: string): HttpError => ({ _tag: "NetworkError", message }), 49 37 timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }), ··· 58 46 } 59 47 60 48 // ----------------------------------------------------------------------------- 61 - // SOLUTION: Fetch as an effect with automatic abort cleanup 49 + // Fetch as an effect - cleanup is handled by the runtime 62 50 // ----------------------------------------------------------------------------- 63 51 64 52 const fetchUser = (url: string): Eff<User, HttpError, unknown> => 65 53 async((resume) => { 66 - // SOLUTION: AbortController is managed automatically 67 54 const controller = new AbortController() 68 55 69 56 console.log(`[Fetch] ${url}`) ··· 80 67 } 81 68 }) 82 69 .catch((err) => { 83 - if (err.name === "AbortError") { 84 - // SOLUTION: Interrupted - cleanup runs automatically, no resume needed 85 - return 86 - } 70 + if (err.name === "AbortError") return // Interrupted, cleanup will run 87 71 resume(Exit.fail(HttpError.network(err.message))) 88 72 }) 89 73 90 - // SOLUTION: Return cleanup function - runtime calls it on interrupt! 74 + // Return cleanup - called automatically on timeout or interruption 91 75 return () => { 92 76 console.log("[Cleanup] Aborting request") 93 77 controller.abort() ··· 95 79 }) 96 80 97 81 // ----------------------------------------------------------------------------- 98 - // SOLUTION: Pattern matching - compiler forces handling ALL error cases 82 + // Error handling - try removing a case, TypeScript will complain 99 83 // ----------------------------------------------------------------------------- 100 84 101 85 const handleError = (error: HttpError): string => 102 86 match(error)({ 103 - // SOLUTION: Try removing one case - TypeScript will error! 104 87 NetworkError: ({ message }) => `Network failed: ${message}`, 105 88 TimeoutError: ({ ms }) => `Request timed out after ${ms}ms`, 106 89 NotFound: ({ url }) => `Resource not found: ${url}`, ··· 108 91 }) 109 92 110 93 // ----------------------------------------------------------------------------- 111 - // Demo - run it 94 + // Demo 112 95 // ----------------------------------------------------------------------------- 113 96 114 97 const main = async () => { 115 98 console.log("=== HTTP Client (with purus) ===\n") 116 99 117 - // Test 1: Successful request with retry + timeout 100 + // Test 1: Successful request 118 101 console.log("--- Test 1: Successful request ---") 119 102 120 - // SOLUTION: Composable one-liner! retry(2) + timeout(5000) 121 103 const request1 = pipe( 122 104 fetchUser("https://jsonplaceholder.typicode.com/users/1"), 123 105 retry(2), ··· 136 118 console.log(`Error: ${handleError(e as HttpError)}\n`) 137 119 } 138 120 139 - // Test 2: Short timeout 121 + // Test 2: Short timeout - will fail, but we recover 140 122 console.log("--- Test 2: Short timeout ---") 141 123 142 124 const request2 = pipe( 143 125 fetchUser("https://jsonplaceholder.typicode.com/users/1"), 144 - timeout(1), // 1ms - will timeout 126 + timeout(1), 145 127 flatMap((result) => 146 128 result === null 147 129 ? fail(HttpError.timeout(1)) 148 130 : succeed(result) 149 131 ), 150 - // SOLUTION: catchAll provides typed error recovery 151 132 catchAll((error) => { 152 133 console.log(`[Caught] ${handleError(error)}`) 153 134 return succeed({ id: 0, name: "Timeout Fallback", email: "" }) ··· 157 138 const fallbackUser = await runPromise(request2) 158 139 console.log(`Result: ${fallbackUser.name}\n`) 159 140 160 - // Test 3: 404 error 141 + // Test 3: 404 error - recover with default 161 142 console.log("--- Test 3: 404 Not Found ---") 162 143 163 144 const request3 = pipe( 164 145 fetchUser("https://jsonplaceholder.typicode.com/users/99999"), 165 146 retry(0), 166 147 catchAll((error) => { 167 - // SOLUTION: error is HttpError, not unknown! 168 148 console.log(`[Caught] ${handleError(error)}`) 169 149 return succeed({ id: 0, name: "Default User", email: "" }) 170 150 })
+15 -24
examples/http-client/without-purus.ts
··· 1 1 /** 2 - * HTTP Client - WITHOUT purus 2 + * HTTP Client - vanilla TypeScript 3 3 * 4 - * This is realistic vanilla TypeScript code showing how fetch + retry + timeout 5 - * is typically implemented. Notice the pain points marked with "PROBLEM:". 4 + * Typical fetch implementation with retry and timeout. 5 + * Read through and spot the gotchas - there are a few. 6 6 */ 7 7 8 8 type User = { ··· 12 12 } 13 13 14 14 // ----------------------------------------------------------------------------- 15 - // Fetch with retry and timeout - vanilla implementation 15 + // Fetch with retry and timeout 16 16 // ----------------------------------------------------------------------------- 17 17 18 18 const fetchWithRetryAndTimeout = async ( ··· 27 27 let lastError: unknown 28 28 29 29 for (let attempt = 0; attempt <= retries; attempt++) { 30 - // PROBLEM: Manual AbortController management - easy to forget cleanup 31 30 const controller = new AbortController() 32 31 let timeoutId: ReturnType<typeof setTimeout> | undefined 33 32 34 33 try { 35 - // PROBLEM: setTimeout + AbortController coordination is tricky 34 + // Wire up the timeout to abort the request 36 35 timeoutId = setTimeout(() => controller.abort(), timeoutMs) 37 36 38 37 console.log(`[Attempt ${attempt + 1}/${retries + 1}] Fetching ${url}...`) 39 38 40 39 const response = await fetch(url, { signal: controller.signal }) 41 40 42 - // PROBLEM: Must remember to clear timeout in success path 43 - clearTimeout(timeoutId) 41 + clearTimeout(timeoutId) // Don't forget this! 44 42 45 43 if (!response.ok) { 46 - // PROBLEM: What if we forget clearTimeout here? Memory leak! 44 + // Whoops - did you remember clearTimeout before throwing? 47 45 throw new Error(`HTTP ${response.status}: ${response.statusText}`) 48 46 } 49 47 ··· 52 50 return data as User 53 51 54 52 } catch (e: unknown) { 55 - // PROBLEM: clearTimeout is easy to forget in catch block! 53 + // Need to clean up here too - easy to miss 56 54 if (timeoutId) clearTimeout(timeoutId) 57 55 58 - // PROBLEM: e is `unknown` - we don't know what errors can occur 59 - lastError = e 56 + lastError = e // e is unknown - we'll deal with it later... somehow 60 57 61 58 const isTimeout = e instanceof Error && e.name === "AbortError" 62 59 const message = e instanceof Error ? e.message : "Unknown error" 63 60 64 61 console.log(`[Attempt ${attempt + 1}] Failed: ${isTimeout ? "Timeout" : message}`) 65 62 66 - // PROBLEM: Retry logic is scattered and mixed with error handling 67 63 if (attempt < retries) { 68 - const delay = retryDelayMs * Math.pow(2, attempt) // Exponential backoff 64 + const delay = retryDelayMs * Math.pow(2, attempt) 69 65 console.log(`[Retry] Waiting ${delay}ms before retry...`) 70 66 await new Promise(resolve => setTimeout(resolve, delay)) 71 67 } 72 68 } 73 69 } 74 70 75 - // PROBLEM: We throw `unknown` - caller has no idea what to catch 71 + // Caller gets unknown - good luck figuring out what went wrong 76 72 throw lastError 77 73 } 78 74 79 75 // ----------------------------------------------------------------------------- 80 - // Demo - run it 76 + // Demo 81 77 // ----------------------------------------------------------------------------- 82 78 83 79 const main = async () => { 84 80 console.log("=== HTTP Client (without purus) ===\n") 85 81 86 82 try { 87 - // Test 1: Successful request 88 83 console.log("--- Test 1: Successful request ---") 89 84 const user = await fetchWithRetryAndTimeout( 90 85 "https://jsonplaceholder.typicode.com/users/1", ··· 93 88 console.log(`Got user: ${user.name} (${user.email})\n`) 94 89 95 90 } catch (e: unknown) { 96 - // PROBLEM: We caught `unknown` - what is it? NetworkError? Timeout? 404? 97 - // TypeScript can't help us here. We have to guess and check manually. 91 + // What did we catch? Network error? Timeout? 404? 500? 92 + // TypeScript shrugs. We have to guess. 98 93 console.error("Failed:", e instanceof Error ? e.message : e) 99 94 } 100 95 101 96 try { 102 - // Test 2: Request with short timeout (will likely timeout) 103 97 console.log("--- Test 2: Short timeout ---") 104 98 const user = await fetchWithRetryAndTimeout( 105 99 "https://jsonplaceholder.typicode.com/users/1", 106 - { retries: 1, timeoutMs: 1 } // 1ms timeout - will fail 100 + { retries: 1, timeoutMs: 1 } 107 101 ) 108 102 console.log(`Got user: ${user.name}\n`) 109 103 110 104 } catch (e: unknown) { 111 - // PROBLEM: Same issue - e is unknown, no compiler help 112 105 console.log("Expected timeout:", e instanceof Error ? e.message : e) 113 106 console.log() 114 107 } 115 108 116 109 try { 117 - // Test 3: 404 error 118 110 console.log("--- Test 3: 404 Not Found ---") 119 111 const user = await fetchWithRetryAndTimeout( 120 112 "https://jsonplaceholder.typicode.com/users/99999", ··· 123 115 console.log(`Got user: ${user.name}\n`) 124 116 125 117 } catch (e: unknown) { 126 - // PROBLEM: Is this a 404? 500? Network error? We don't know at compile time! 127 118 console.log("Expected 404:", e instanceof Error ? e.message : e) 128 119 console.log() 129 120 }
+16 -22
examples/task-queue/README.md
··· 1 1 # Task Queue 2 2 3 - > Background job processing with retries and timeouts shouldn't require a framework. 3 + Background job processing with real cancellation and easy testing. 4 4 5 5 ## The Problem 6 6 7 - Background jobs are everywhere: sending emails, processing images, syncing data. Each job needs: 8 - - **Retry logic** for transient failures 9 - - **Timeouts** so jobs don't run forever 10 - - **Error handling** with context preserved 11 - - **Testability** - easy to mock dependencies 12 - 13 - In vanilla TypeScript, you end up with manual Promise tracking, scattered try/catch blocks, and code that's impossible to test without real dependencies. 7 + Background jobs need retries, timeouts, and error handling. In vanilla TypeScript: 8 + - Promise.race doesn't actually cancel the losing promise 9 + - Dependencies are hardcoded, making tests awkward 10 + - Error context gets lost after retries 14 11 15 12 ## Run Both Versions 16 13 ··· 21 18 22 19 ## Without purus 23 20 24 - See `without-purus.ts` - realistic code showing: 25 - - Manual Promise tracking for workers 26 - - `Promise.race` for timeout (doesn't cancel the job!) 27 - - Lost error context after retries fail 28 - - Hard to test - dependencies are hardcoded 21 + See `without-purus.ts`: 22 + - Promise.race for timeout (but the job keeps running!) 23 + - Hardcoded logger - can't mock without a DI framework 24 + - Errors are `unknown` after retry exhaustion 29 25 30 26 ## With purus 31 27 32 - See `with-purus.ts` - same functionality with: 33 - - `fork`/`join` for fiber-based workers 34 - - `timeout(30000)` actually cancels the job 35 - - `catchAll` preserves error context 36 - - `provide(mockEnv)` makes testing trivial 28 + See `with-purus.ts`: 29 + - timeout() returns cleanup function - job actually stops 30 + - provide(env) injects dependencies - swap for tests 31 + - Typed errors preserved through the pipeline 37 32 38 33 ## Key Takeaways 39 34 40 - - **Fibers > Promises** → Real cancellation, not just ignoring results 41 - - **Composable timeouts** → `timeout()` is just another combinator 42 - - **Error context preserved** → `catchAll` sees the original error 43 - - **Dependency injection** → `provide()` makes testing trivial 35 + - Return a cleanup function from async() for real cancellation 36 + - provide() makes testing easy without frameworks 37 + - catchAll() preserves error context
+14 -41
examples/task-queue/with-purus.ts
··· 1 1 /** 2 - * Task Queue - WITH purus 2 + * Task Queue - with purus 3 3 * 4 - * Same functionality as without-purus.ts, but with: 5 - * - Fiber-based execution with real cancellation 6 - * - Composable retry/timeout combinators 7 - * - Error context preserved through the pipeline 8 - * - Dependency injection for easy testing 4 + * Same job processor, but: 5 + * - timeout() actually cancels the job 6 + * - Dependencies are injected (easy to test) 7 + * - Errors are typed, not unknown 9 8 */ 10 9 11 10 import { 12 - // Types 13 11 type Eff, 14 12 Exit, 15 - 16 - // Constructors 17 13 succeed, 18 14 fail, 19 15 async, 20 - 21 - // Transformations 22 16 flatMap, 23 17 mapEff, 24 18 catchAll, 25 - 26 - // Environment 27 19 accessEff, 28 20 provide, 29 - 30 - // Combinators 31 21 timeout, 32 22 retry, 33 23 allSequential, 34 - 35 - // Composition 36 24 pipe, 37 - 38 - // Runners 39 25 runPromise, 40 26 } from "../../src/index" 41 27 42 28 // ----------------------------------------------------------------------------- 43 - // Job Types 29 + // Job types 44 30 // ----------------------------------------------------------------------------- 45 31 46 32 type Job = { ··· 49 35 payload: Record<string, unknown> 50 36 } 51 37 52 - // SOLUTION: Typed error union - compiler knows all failure modes 53 38 type JobError = 54 39 | { readonly _tag: "TransientError"; readonly jobId: string; readonly message: string } 55 40 | { readonly _tag: "TimeoutError"; readonly jobId: string; readonly ms: number } ··· 61 46 ({ _tag: "TimeoutError", jobId, ms }), 62 47 } 63 48 64 - // SOLUTION: Environment type for dependency injection 49 + // Environment - swap this out in tests 65 50 type QueueEnv = { 66 51 logger: { 67 52 info: (msg: string) => void ··· 70 55 } 71 56 72 57 // ----------------------------------------------------------------------------- 73 - // Job Execution 58 + // Job execution - with real cancellation 74 59 // ----------------------------------------------------------------------------- 75 60 76 - // SOLUTION: executeJob returns an Eff with cleanup function for real cancellation 77 61 const executeJob = (job: Job): Eff<void, JobError, QueueEnv> => 78 62 async((resume) => { 79 - // SOLUTION: Cancellation token - cleanup function can abort this 80 63 let cancelled = false 81 64 const delay = Math.random() * 200 + 50 82 65 83 66 const timeoutId = setTimeout(() => { 84 - if (cancelled) return // SOLUTION: Check cancellation before resuming 67 + if (cancelled) return // Already cancelled, don't resume 85 68 86 - // Simulate 30% failure rate 87 69 if (Math.random() < 0.3) { 88 70 resume(Exit.fail(JobError.transient(job.id, "transient error"))) 89 71 } else { ··· 91 73 } 92 74 }, delay) 93 75 94 - // SOLUTION: Return cleanup function - runtime calls this on timeout/interrupt 76 + // Return cleanup - called on timeout or interrupt 95 77 return () => { 96 78 cancelled = true 97 79 clearTimeout(timeoutId) ··· 99 81 }) 100 82 101 83 // ----------------------------------------------------------------------------- 102 - // Job Processing Pipeline 84 + // Job pipeline 103 85 // ----------------------------------------------------------------------------- 104 86 105 87 const TIMEOUT_MS = 5000 106 88 const MAX_RETRIES = 3 107 89 108 - // SOLUTION: Composable pipeline - each combinator is just a function 109 90 const processJob = (job: Job): Eff<void, never, QueueEnv> => 110 91 pipe( 111 - // SOLUTION: accessEff to get logger from environment 112 92 accessEff((env: QueueEnv) => 113 93 pipe( 114 94 succeed(undefined), ··· 118 98 }) 119 99 ) 120 100 ), 121 - // SOLUTION: retry(3) - composable, not scattered through code 122 101 retry(MAX_RETRIES), 123 - // SOLUTION: timeout actually cancels the job via cleanup function! 124 102 timeout(TIMEOUT_MS), 125 103 flatMap((result) => 126 104 result === null 127 105 ? fail(JobError.timeout(job.id, TIMEOUT_MS)) 128 106 : succeed(result) 129 107 ), 130 - // SOLUTION: catchAll preserves typed error context 131 108 catchAll((error: JobError) => 132 109 accessEff((env: QueueEnv) => { 133 - // SOLUTION: error is JobError, not unknown - full type information! 134 110 const msg = error._tag === "TimeoutError" 135 111 ? `Job ${error.jobId} timed out after ${error.ms}ms` 136 112 : `Job ${error.jobId} failed: ${error.message}` ··· 138 114 return succeed(undefined) 139 115 }) 140 116 ), 141 - // SOLUTION: Log success using environment 142 117 flatMap(() => 143 118 accessEff((env: QueueEnv) => { 144 119 env.logger.info(`Job ${job.id} completed`) ··· 148 123 ) 149 124 150 125 // ----------------------------------------------------------------------------- 151 - // Queue Processing 126 + // Queue processing 152 127 // ----------------------------------------------------------------------------- 153 128 154 129 const processQueue = (jobs: Job[]): Eff<void, never, QueueEnv> => ··· 161 136 // Environments 162 137 // ----------------------------------------------------------------------------- 163 138 164 - // Production environment 165 139 const prodEnv: QueueEnv = { 166 140 logger: { 167 141 info: (msg) => console.log(`[INFO] ${msg}`), ··· 169 143 }, 170 144 } 171 145 172 - // SOLUTION: Test environment - just provide a different object! 146 + // For tests - just swap provide(prodEnv) with provide(testEnv) 173 147 const _testEnv: QueueEnv = { 174 148 logger: { 175 - info: () => {}, // Silent for tests 149 + info: () => {}, 176 150 error: () => {}, 177 151 }, 178 152 } ··· 190 164 { id: "job-3", type: "sync", payload: { source: "db", target: "cache" } }, 191 165 ] 192 166 193 - // SOLUTION: provide() injects dependencies - swap prodEnv for _testEnv in tests! 194 167 const program = pipe( 195 168 processQueue(jobs), 196 169 provide(prodEnv)
+13 -20
examples/task-queue/without-purus.ts
··· 1 1 /** 2 - * Task Queue - WITHOUT purus 2 + * Task Queue - vanilla TypeScript 3 3 * 4 - * This is realistic vanilla TypeScript code for background job processing. 5 - * Notice the pain points marked with "PROBLEM:". 4 + * A simple background job processor. Note how Promise.race 5 + * doesn't actually cancel the losing promise. 6 6 */ 7 7 8 8 // ----------------------------------------------------------------------------- 9 - // Job Types 9 + // Job types 10 10 // ----------------------------------------------------------------------------- 11 11 12 12 type Job = { ··· 15 15 payload: Record<string, unknown> 16 16 } 17 17 18 - // PROBLEM: Hardcoded logger - can't mock for testing without dependency injection framework 18 + // Hardcoded logger - awkward to mock in tests 19 19 const logger = { 20 20 info: (msg: string) => console.log(`[INFO] ${msg}`), 21 21 error: (msg: string) => console.log(`[ERROR] ${msg}`), 22 22 } 23 23 24 24 // ----------------------------------------------------------------------------- 25 - // Job Execution with Timeout 25 + // Job execution 26 26 // ----------------------------------------------------------------------------- 27 27 28 28 const executeJob = async (job: Job): Promise<void> => { 29 - // Simulate job processing 30 29 const delay = Math.random() * 200 + 50 31 30 await new Promise(resolve => setTimeout(resolve, delay)) 32 31 33 - // Simulate 30% failure rate 32 + // 30% chance of failure 34 33 if (Math.random() < 0.3) { 35 34 throw new Error(`Job ${job.id} failed: transient error`) 36 35 } ··· 39 38 } 40 39 41 40 const executeWithTimeout = async (job: Job, timeoutMs: number): Promise<void> => { 42 - // PROBLEM: Promise.race doesn't cancel the losing promise! 43 - // The job keeps running even after timeout - we just ignore the result 41 + // Promise.race returns when one settles, but the loser keeps running! 42 + // We're just ignoring the result, not actually cancelling the job. 44 43 const result = await Promise.race([ 45 44 executeJob(job), 46 45 new Promise<never>((_, reject) => ··· 51 50 } 52 51 53 52 // ----------------------------------------------------------------------------- 54 - // Retry Logic 53 + // Retry logic 55 54 // ----------------------------------------------------------------------------- 56 55 57 56 const executeWithRetry = async ( ··· 65 64 try { 66 65 logger.info(`[Attempt ${attempt}/${maxRetries}] Processing job ${job.id}`) 67 66 await executeWithTimeout(job, timeoutMs) 68 - return // Success! 67 + return 69 68 } catch (e) { 70 - // PROBLEM: We lose the original error context after multiple retries 71 - // Only the last error is preserved 72 69 lastError = e 73 70 logger.error(`Attempt ${attempt} failed: ${e instanceof Error ? e.message : e}`) 74 71 } 75 72 } 76 73 77 - // PROBLEM: Throwing unknown - caller has no type information about the error 78 - throw lastError 74 + throw lastError // Caller gets unknown 79 75 } 80 76 81 77 // ----------------------------------------------------------------------------- 82 - // Queue Processing 78 + // Queue processing 83 79 // ----------------------------------------------------------------------------- 84 80 85 81 const processQueue = async (jobs: Job[]): Promise<void> => { 86 - // PROBLEM: Manual promise tracking - easy to mess up 87 82 const results: Array<{ job: Job; success: boolean; error?: unknown }> = [] 88 83 89 84 for (const job of jobs) { ··· 91 86 await executeWithRetry(job, 3, 5000) 92 87 results.push({ job, success: true }) 93 88 } catch (e) { 94 - // PROBLEM: e is unknown - what kind of error was it? Timeout? Job failure? 95 89 results.push({ job, success: false, error: e }) 96 90 } 97 91 } 98 92 99 - // Summary 100 93 const succeeded = results.filter(r => r.success).length 101 94 const failed = results.filter(r => !r.success).length 102 95 logger.info(`Queue complete: ${succeeded} succeeded, ${failed} failed`)
+18 -22
examples/workflow-engine/README.md
··· 1 1 # Workflow Engine 2 2 3 - > Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible. 3 + Order processing where IDs can't be swapped and invalid transitions don't compile. 4 4 5 5 ## The Problem 6 6 7 - Business logic is full of: 8 - - **IDs that look alike** - OrderId, CustomerId, ProductId are all strings 9 - - **State transitions** - Can't ship an unpaid order, can't refund a draft 10 - - **Error cases** - OutOfStock, PaymentDeclined, InvalidAddress... 7 + Business logic has: 8 + - IDs that look alike (OrderId, CustomerId - both strings) 9 + - State transitions (can't ship before paying) 10 + - Error cases you might forget to handle 11 11 12 - In vanilla TypeScript: 13 - - Swapping `orderId` and `customerId` compiles fine, fails at runtime 14 - - State checks are scattered `if (order.status === 'paid')` everywhere 15 - - `catch (e: unknown)` means you're guessing what errors can happen 12 + In vanilla TypeScript, swapping `orderId` and `customerId` compiles fine. 13 + Shipping an unpaid order throws at runtime. 16 14 17 15 ## Run Both Versions 18 16 ··· 24 22 ## Without purus 25 23 26 24 See `without-purus.ts`: 27 - - All IDs are `string` - easy to swap arguments 28 - - Runtime status checks before every operation 29 - - `switch` statements with forgotten cases 30 - - Errors as thrown exceptions with unknown shape 25 + - Type aliases (`type OrderId = string`) don't prevent mixups 26 + - Runtime checks (`if (order.status !== 'paid')`) 27 + - String matching in error handling 28 + - There's a bug in lookupOrder - can you spot it? 31 29 32 30 ## With purus 33 31 34 - See `with-purus.ts` - same functionality with: 35 - - Branded types: `OrderId` and `CustomerId` can't be mixed 36 - - Typestate: `ship(order: PaidOrder)` - can't ship unpaid at compile time 37 - - `match()` forces handling ALL error cases 38 - - Result type makes errors explicit values 32 + See `with-purus.ts`: 33 + - Branded types: `OrderId` and `CustomerId` are incompatible 34 + - Typestate: `shipOrder(order: PaidOrder)` - can't pass a draft 35 + - match() forces you to handle every error case 39 36 40 37 ## Key Takeaways 41 38 42 - - **Branded types** -> Can't pass OrderId where CustomerId expected 43 - - **Typestate** -> Invalid transitions are compile errors, not runtime checks 44 - - **Exhaustive matching** -> Forget an error case = compile error 45 - - **Errors as values** -> No more `catch (e: unknown)` guessing 39 + - Branded types catch ID mixups at compile time 40 + - Typestate turns runtime checks into type errors 41 + - Exhaustive matching means you can't forget error cases
+25 -37
examples/workflow-engine/with-purus.ts
··· 1 1 /** 2 - * Workflow Engine - WITH purus 2 + * Workflow Engine - with purus 3 3 * 4 - * Same functionality as without-purus.ts, but with: 5 - * - Branded types: OrderId and CustomerId can't be mixed 6 - * - Typestate: ship(order: PaidOrder) - can't ship unpaid at compile time 7 - * - Exhaustive matching: forget an error case = compile error 8 - * - Errors as values: no more catch (e: unknown) guessing 4 + * Same order flow, but: 5 + * - Branded types: can't swap OrderId and CustomerId 6 + * - Typestate: shipOrder only accepts PaidOrder 7 + * - match(): forget an error case = compile error 9 8 */ 10 9 11 10 import { 12 - // Types 13 11 type Branded, 14 12 type Entity, 15 13 type Result, 16 - 17 - // Constructors 18 14 brand, 19 15 entity, 20 16 transition, 21 17 ok, 22 18 err, 23 - 24 - // Pattern matching 25 19 match, 26 20 matchResult, 27 21 } from "../../src/index" 28 22 29 23 // ----------------------------------------------------------------------------- 30 - // SOLUTION: Branded types - OrderId and CustomerId are distinct! 24 + // Branded types - OrderId and CustomerId can't be mixed up 31 25 // ----------------------------------------------------------------------------- 32 26 33 27 type OrderId = Branded<string, "OrderId"> ··· 38 32 39 33 type ProductId = Branded<string, "ProductId"> 40 34 const ProductId = (s: string): ProductId => brand(s) 35 + 36 + // Try passing a CustomerId where OrderId is expected - TypeScript will stop you. 41 37 42 38 // ----------------------------------------------------------------------------- 43 - // SOLUTION: Typestate - Order states are separate types! 39 + // Typestate - each order state is a different type 44 40 // ----------------------------------------------------------------------------- 45 41 46 42 type OrderData = { ··· 50 46 readonly quantity: number 51 47 } 52 48 53 - // SOLUTION: Each state is a distinct type - not a string field! 54 49 type DraftOrder = Entity<OrderData, "Draft"> 55 50 type PaidOrder = Entity<OrderData, "Paid"> 56 51 type ShippedOrder = Entity<OrderData, "Shipped"> 57 52 58 - // SOLUTION: Typed errors - compiler knows all failure modes 53 + // No status field to check - the type IS the status. 54 + 59 55 type OrderError = 60 56 | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string } 61 57 | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId } ··· 71 67 } 72 68 73 69 // ----------------------------------------------------------------------------- 74 - // SOLUTION: State transitions are type-safe functions 70 + // State transitions - the types enforce valid transitions 75 71 // ----------------------------------------------------------------------------- 76 72 77 - // SOLUTION: Only accepts DraftOrder - can't pass PaidOrder or ShippedOrder! 73 + // Only accepts DraftOrder. Try passing a ShippedOrder - won't compile. 78 74 const payOrder = (order: DraftOrder): Result<PaidOrder, OrderError> => { 79 75 console.log(`[Payment] Processing payment for order ${order.id}`) 80 - // Transition from Draft to Paid 81 76 const toPaid = transition<OrderData, "Draft", "Paid">() 82 77 return ok(toPaid(order)) 83 78 } 84 79 85 - // SOLUTION: Only accepts PaidOrder - compile error if you try to ship a draft! 80 + // Only accepts PaidOrder. No runtime check needed. 86 81 const shipOrder = (order: PaidOrder): ShippedOrder => { 87 82 console.log(`[Shipping] Shipping order ${order.id}`) 88 - // Transition from Paid to Shipped 89 83 const toShipped = transition<OrderData, "Paid", "Shipped">() 90 84 return toShipped(order) 91 85 } 92 86 93 - // SOLUTION: Arguments have distinct types - swapping them is a compile error! 87 + // Branded types prevent the bug from the vanilla version 94 88 const lookupOrder = ( 95 - orderId: OrderId, // <-- Branded type 96 - customerId: CustomerId // <-- Different branded type 89 + orderId: OrderId, 90 + customerId: CustomerId 97 91 ): Result<DraftOrder, OrderError> => { 98 92 if (orderId === "order-1" && customerId === "cust-1") { 99 93 const data: OrderData = { ··· 107 101 return err(OrderError.orderNotFound(orderId)) 108 102 } 109 103 110 - // SOLUTION: This would be a compile error! 111 - // function buggyLookup() { 104 + // This won't compile - TypeScript catches the swapped arguments: 105 + // const buggyLookup = () => { 112 106 // const customerId = CustomerId("cust-1") 113 107 // const orderId = OrderId("order-1") 114 - // // ERROR: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId' 115 - // return lookupOrder(customerId, orderId) 108 + // return lookupOrder(customerId, orderId) // Error! 116 109 // } 117 110 118 111 // ----------------------------------------------------------------------------- 119 - // SOLUTION: Exhaustive pattern matching - can't forget error cases 112 + // Error handling - exhaustive matching 120 113 // ----------------------------------------------------------------------------- 121 114 122 - const formatError = (error: OrderError): string => { 123 - // SOLUTION: If you add a new error type and forget to handle it, compiler errors! 124 - return match(error)({ 115 + const formatError = (error: OrderError): string => 116 + // Try removing a case - TypeScript will complain 117 + match(error)({ 125 118 InvalidTransition: ({ from, to }) => 126 119 `Cannot transition order from ${from} to ${to}`, 127 120 OrderNotFound: ({ orderId }) => ··· 129 122 PaymentDeclined: ({ reason }) => 130 123 `Payment declined: ${reason}`, 131 124 }) 132 - } 133 125 134 126 // ----------------------------------------------------------------------------- 135 - // Demo - run it 127 + // Demo 136 128 // ----------------------------------------------------------------------------- 137 129 138 130 const main = () => { 139 131 console.log("=== Workflow Engine (with purus) ===\n") 140 132 141 - // Test 1: Successful flow 142 133 console.log("--- Test 1: Successful order flow ---") 143 134 144 135 const orderId = OrderId("order-1") ··· 151 142 const payResult = payOrder(draft) 152 143 matchResult<PaidOrder, OrderError, void>( 153 144 (paid) => { 154 - // SOLUTION: shipOrder only accepts PaidOrder - type-safe! 155 145 const shipped = shipOrder(paid) 156 146 console.log(`Order ${shipped.id} shipped successfully!\n`) 157 147 }, ··· 161 151 (error) => console.log(`Error: ${formatError(error)}\n`) 162 152 )(result) 163 153 164 - // Test 2: Type safety demonstration 165 154 console.log("--- Test 2: Type safety (compile-time protection) ---") 166 155 console.log("The following would be compile errors in purus:") 167 156 console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder") 168 157 console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId") 169 158 console.log("These bugs are caught at compile time, not runtime!\n") 170 159 171 - // Test 3: Order not found 172 160 console.log("--- Test 3: Order not found ---") 173 161 const notFoundResult = lookupOrder(OrderId("invalid"), customerId) 174 162 matchResult<DraftOrder, OrderError, void>(
+14 -24
examples/workflow-engine/without-purus.ts
··· 1 1 /** 2 - * Workflow Engine - WITHOUT purus 2 + * Workflow Engine - vanilla TypeScript 3 3 * 4 - * This is realistic vanilla TypeScript code showing how order processing 5 - * is typically implemented. Notice the pain points marked with "PROBLEM:". 4 + * A simple order processing flow. Watch for the bug in lookupOrder - 5 + * it compiles fine but fails at runtime. 6 6 */ 7 7 8 8 // ----------------------------------------------------------------------------- 9 - // IDs - just type aliases to string 9 + // IDs - type aliases don't prevent mixups 10 10 // ----------------------------------------------------------------------------- 11 11 12 - // PROBLEM: These are all just strings at runtime - easy to mix up! 13 12 type OrderId = string 14 13 type CustomerId = string 15 14 type ProductId = string 16 15 16 + // All three are just strings, so swapping them won't cause a compile error. 17 + 17 18 // ----------------------------------------------------------------------------- 18 19 // Order with status field 19 20 // ----------------------------------------------------------------------------- ··· 29 30 } 30 31 31 32 // ----------------------------------------------------------------------------- 32 - // Error handling via thrown exceptions 33 + // State transitions with runtime checks 33 34 // ----------------------------------------------------------------------------- 34 35 35 36 const payOrder = (order: Order): Order => { 36 - // PROBLEM: Runtime check that could be forgotten 37 37 if (order.status !== "draft") { 38 38 throw new Error(`Cannot pay order in ${order.status} status`) 39 39 } ··· 42 42 } 43 43 44 44 const shipOrder = (order: Order): Order => { 45 - // PROBLEM: Another runtime check - easy to forget or get wrong 46 45 if (order.status !== "paid") { 47 46 throw new Error(`Cannot ship order in ${order.status} status`) 48 47 } ··· 50 49 return { ...order, status: "shipped" } 51 50 } 52 51 53 - // PROBLEM: Arguments are all strings - swapping them compiles fine! 52 + // Both arguments are strings - can you spot the bug below? 54 53 const lookupOrder = (orderId: OrderId, customerId: CustomerId): Order | null => { 55 - // Simulate database lookup 56 54 if (orderId === "order-1" && customerId === "cust-1") { 57 55 return { 58 56 id: orderId, ··· 65 63 return null 66 64 } 67 65 68 - // PROBLEM: This has a bug - arguments are swapped! But it compiles fine. 66 + // This compiles fine, but the arguments are backwards! 69 67 const buggyLookup = () => { 70 68 const customerId: CustomerId = "cust-1" 71 69 const orderId: OrderId = "order-1" 72 70 73 - // PROBLEM: We swapped the arguments - this compiles but returns null! 74 - const order = lookupOrder(customerId, orderId) // <-- BUG: swapped! 71 + const order = lookupOrder(customerId, orderId) // Oops - swapped! 75 72 return order 76 73 } 77 74 78 75 // ----------------------------------------------------------------------------- 79 - // Error formatting with switch 76 + // Error handling 80 77 // ----------------------------------------------------------------------------- 81 78 82 79 const formatError = (error: unknown): string => { 83 80 if (error instanceof Error) { 84 81 const message = error.message 85 82 86 - // PROBLEM: String matching is fragile and not exhaustive 83 + // String matching is fragile - what if the message changes? 87 84 if (message.includes("Cannot pay")) { 88 85 return "Payment failed: Order is not in draft status" 89 86 } 90 87 if (message.includes("Cannot ship")) { 91 88 return "Shipping failed: Order is not paid" 92 89 } 93 - // PROBLEM: Easy to forget cases - no compiler warning! 94 90 return `Unknown error: ${message}` 95 91 } 96 92 return "Unknown error" 97 93 } 98 94 99 95 // ----------------------------------------------------------------------------- 100 - // Demo - run it 96 + // Demo 101 97 // ----------------------------------------------------------------------------- 102 98 103 99 const main = () => { 104 100 console.log("=== Workflow Engine (without purus) ===\n") 105 101 106 - // Test 1: Successful flow 107 102 console.log("--- Test 1: Successful order flow ---") 108 103 try { 109 104 const order: Order = { ··· 118 113 const shipped = shipOrder(paid) 119 114 console.log(`Order ${shipped.id} shipped successfully!\n`) 120 115 } catch (e: unknown) { 121 - // PROBLEM: e is unknown - we have to guess what it might be 122 116 console.log(formatError(e)) 123 117 } 124 118 125 - // Test 2: Invalid transition 126 119 console.log("--- Test 2: Invalid state transition ---") 127 120 try { 128 121 const order: Order = { ··· 133 126 status: "draft", 134 127 } 135 128 136 - // PROBLEM: Trying to ship a draft order - fails at runtime, not compile time! 129 + // This throws at runtime - would be nice to catch at compile time 137 130 const shipped = shipOrder(order) 138 131 console.log(`Shipped: ${shipped.id}\n`) 139 132 } catch (e: unknown) { 140 133 console.log(`Error: ${formatError(e)}\n`) 141 134 } 142 135 143 - // Test 3: Swapped arguments bug 144 136 console.log("--- Test 3: Swapped arguments bug ---") 145 137 const order = buggyLookup() 146 138 if (order === null) { 147 - // PROBLEM: This fails at runtime because we swapped orderId/customerId 148 - // TypeScript didn't catch the bug because both are just `string` 149 139 console.log("Order not found (bug: arguments were swapped!)\n") 150 140 } 151 141