An educational pure functional programming library in TypeScript
2
fork

Configure Feed

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

Add educational comments to http-client example

+170 -39
+101 -23
examples/http-client/with-purus.ts
··· 1 1 /** 2 - * HTTP Client - with purus 2 + * HTTP Client Example - Building Resilient Data Fetching 3 + * ====================================================== 4 + * 5 + * This example teaches three core purus concepts: 6 + * 7 + * 1. TYPED ERRORS - Know exactly what can fail (no `catch (e: unknown)`) 8 + * The HttpError union has specific variants, so match() forces you to 9 + * handle each case. Try commenting out a case - the compiler stops you. 10 + * 11 + * 2. COMPOSABLE OPERATIONS - pipe(), retry(), timeout() chain cleanly 12 + * Each combinator is a pure function that transforms an Eff. You can 13 + * add/remove/reorder them without restructuring your code. 14 + * 15 + * 3. AUTOMATIC CLEANUP - Return a cleanup function, never forget to clearTimeout 16 + * The async() constructor takes a register function that returns a cleanup. 17 + * When the effect is cancelled (timeout, interrupt), cleanup runs automatically. 3 18 * 4 - * Same fetch + retry + timeout, but: 5 - * - Errors are typed (no more `unknown`) 6 - * - Cleanup happens automatically 7 - * - Retry/timeout are just composable functions 19 + * Compare with without-purus.ts to see how vanilla TypeScript handles the same 20 + * problems (spoiler: lots of manual AbortController wiring and `e: unknown`). 21 + * 22 + * Prerequisites: Basic Promise understanding 23 + * Next: workflow-engine for branded types and typestate 8 24 */ 9 25 10 26 import { ··· 22 38 runPromise, 23 39 } from "../../src/index" 24 40 25 - // ----------------------------------------------------------------------------- 26 - // Error types - the compiler knows exactly what can fail 27 - // ----------------------------------------------------------------------------- 41 + // ============================================================================= 42 + // SECTION 1: Error Types 43 + // ============================================================================= 44 + // 45 + // WHY TYPED ERRORS MATTER: 46 + // In vanilla JS, errors are `unknown` in catch blocks. You end up with: 47 + // catch (e) { if (e instanceof Error && e.message.includes("timeout")) ... } 48 + // 49 + // With discriminated unions, each error variant is explicit in the type. 50 + // The match() function forces exhaustive handling - forget a case = compile error. 51 + // 52 + // PATTERN: Each error variant has a _tag (discriminant) and relevant data. 53 + // This is the standard discriminated union pattern in TypeScript. 54 + // ============================================================================= 28 55 29 56 type HttpError = 30 57 | { readonly _tag: "NetworkError"; readonly message: string } ··· 32 59 | { readonly _tag: "NotFound"; readonly url: string } 33 60 | { readonly _tag: "ServerError"; readonly status: number } 34 61 62 + // Smart constructors - these ensure consistent error creation 35 63 const HttpError = { 36 64 network: (message: string): HttpError => ({ _tag: "NetworkError", message }), 37 65 timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }), ··· 45 73 email: string 46 74 } 47 75 48 - // ----------------------------------------------------------------------------- 49 - // Fetch as an effect - cleanup is handled by the runtime 50 - // ----------------------------------------------------------------------------- 76 + // ============================================================================= 77 + // SECTION 2: Fetch as an Effect 78 + // ============================================================================= 79 + // 80 + // WHY USE async() INSTEAD OF fromPromise(): 81 + // The async() constructor gives you control over cleanup. When an effect is 82 + // cancelled (via timeout, race, or manual interrupt), the cleanup function runs. 83 + // 84 + // HOW IT WORKS: 85 + // 1. async() takes a "register" function 86 + // 2. The register function receives a "resume" callback 87 + // 3. You start your async work and call resume(Exit.succeed(value)) or resume(Exit.fail(error)) 88 + // 4. Return a cleanup function - it runs on cancellation 89 + // 90 + // GOTCHA: The AbortController abort() must happen in the cleanup function. 91 + // If you forget to return cleanup, the request keeps running even after timeout! 92 + // ============================================================================= 51 93 52 94 const fetchUser = (url: string): Eff<User, HttpError, unknown> => 53 95 async((resume) => { 96 + // Create an AbortController - we'll abort it on cleanup 54 97 const controller = new AbortController() 55 98 56 99 console.log(`[Fetch] ${url}`) ··· 67 110 } 68 111 }) 69 112 .catch((err) => { 70 - if (err.name === "AbortError") return // Interrupted, cleanup will run 113 + // GOTCHA: Don't resume on AbortError - the fiber is already cancelled 114 + // Resuming after cancellation would cause undefined behavior 115 + if (err.name === "AbortError") return 71 116 resume(Exit.fail(HttpError.network(err.message))) 72 117 }) 73 118 74 - // Return cleanup - called automatically on timeout or interruption 119 + // THE KEY INSIGHT: This cleanup function runs automatically on timeout/interrupt 120 + // No manual finally blocks, no forgetting to clearTimeout, no leaked requests 75 121 return () => { 76 122 console.log("[Cleanup] Aborting request") 77 123 controller.abort() 78 124 } 79 125 }) 80 126 81 - // ----------------------------------------------------------------------------- 82 - // Error handling - try removing a case, TypeScript will complain 83 - // ----------------------------------------------------------------------------- 127 + // ============================================================================= 128 + // SECTION 3: Error Handling with match() 129 + // ============================================================================= 130 + // 131 + // WHY match() OVER switch/if-else: 132 + // - match() is EXHAUSTIVE - add a new error variant, and all match() calls 133 + // that don't handle it become compile errors 134 + // - It's an expression, not a statement, so it always returns a value 135 + // - Each handler receives the correctly narrowed type (e.g., { _tag: "TimeoutError", ms }) 136 + // 137 + // TRY THIS: Comment out one of the cases below. TypeScript will error. 138 + // ============================================================================= 84 139 85 140 const handleError = (error: HttpError): string => 86 141 match(error)({ ··· 90 145 ServerError: ({ status }) => `Server error: ${status}`, 91 146 }) 92 147 93 - // ----------------------------------------------------------------------------- 94 - // Demo 95 - // ----------------------------------------------------------------------------- 148 + // ============================================================================= 149 + // SECTION 4: Demo 150 + // ============================================================================= 151 + // 152 + // COMPOSABILITY IN ACTION: 153 + // Notice how each test builds up functionality using pipe(): 154 + // - fetchUser() is the base effect 155 + // - retry(n) wraps it with retry logic 156 + // - timeout(ms) adds a timeout 157 + // - catchAll() recovers from errors 158 + // 159 + // Each combinator is independent - you can add, remove, or reorder them. 160 + // Compare this to the nested try/catch/finally in without-purus.ts. 161 + // ============================================================================= 96 162 97 163 const main = async () => { 98 164 console.log("=== HTTP Client (with purus) ===\n") 99 165 166 + // --------------------------------------------------------------------------- 100 167 // Test 1: Successful request 168 + // Shows: basic pipe with retry + timeout, happy path 169 + // --------------------------------------------------------------------------- 101 170 console.log("--- Test 1: Successful request ---") 102 171 103 172 const request1 = pipe( 104 173 fetchUser("https://jsonplaceholder.typicode.com/users/1"), 105 - retry(2), 106 - timeout(5000), 174 + retry(2), // Retry up to 2 times on failure 175 + timeout(5000), // Cancel if not done in 5s 107 176 flatMap((result) => 177 + // timeout() returns null on timeout, convert to typed error 108 178 result === null 109 179 ? fail(HttpError.timeout(5000)) 110 180 : succeed(result) ··· 118 188 console.log(`Error: ${handleError(e as HttpError)}\n`) 119 189 } 120 190 191 + // --------------------------------------------------------------------------- 121 192 // Test 2: Short timeout - will fail, but we recover 193 + // Shows: catchAll() for error recovery, returns fallback value 194 + // --------------------------------------------------------------------------- 122 195 console.log("--- Test 2: Short timeout ---") 123 196 124 197 const request2 = pipe( 125 198 fetchUser("https://jsonplaceholder.typicode.com/users/1"), 126 - timeout(1), 199 + timeout(1), // 1ms timeout = guaranteed timeout 127 200 flatMap((result) => 128 201 result === null 129 202 ? fail(HttpError.timeout(1)) 130 203 : succeed(result) 131 204 ), 205 + // RECOVERY: catchAll transforms errors into success values 206 + // The error is typed, so we know exactly what we're catching 132 207 catchAll((error) => { 133 208 console.log(`[Caught] ${handleError(error)}`) 134 209 return succeed({ id: 0, name: "Timeout Fallback", email: "" }) ··· 138 213 const fallbackUser = await runPromise(request2) 139 214 console.log(`Result: ${fallbackUser.name}\n`) 140 215 216 + // --------------------------------------------------------------------------- 141 217 // Test 3: 404 error - recover with default 218 + // Shows: server-side errors flow through typed error channel 219 + // --------------------------------------------------------------------------- 142 220 console.log("--- Test 3: 404 Not Found ---") 143 221 144 222 const request3 = pipe( 145 223 fetchUser("https://jsonplaceholder.typicode.com/users/99999"), 146 - retry(0), 224 + retry(0), // No retries for this test 147 225 catchAll((error) => { 148 226 console.log(`[Caught] ${handleError(error)}`) 149 227 return succeed({ id: 0, name: "Default User", email: "" })
+69 -16
examples/http-client/without-purus.ts
··· 1 1 /** 2 - * HTTP Client - vanilla TypeScript 2 + * HTTP Client - Vanilla TypeScript (Comparison) 3 + * ============================================== 4 + * 5 + * This is the "before" version. Compare with with-purus.ts to see how purus 6 + * improves resilient HTTP fetching. 7 + * 8 + * PROBLEMS THIS APPROACH HAS: 9 + * 10 + * 1. ERRORS ARE UNKNOWN - Every catch block has `e: unknown`. You have to 11 + * instanceof check and message-sniff to figure out what happened. 12 + * What if someone changes the error message? Your handling breaks. 13 + * 14 + * 2. MANUAL CLEANUP - AbortController and setTimeout need careful wiring. 15 + * Notice how many places we call clearTimeout()? Miss one = memory leak. 16 + * The early return on line 45 doesn't clear the timeout before throwing. 17 + * 18 + * 3. RETRY IS BAKED IN - The retry logic is tangled into fetchWithRetryAndTimeout. 19 + * Want retry without timeout? Want timeout without retry? You need separate 20 + * functions or complex option objects. 21 + * 22 + * Read through and count the clearTimeout() calls. Ask yourself: did we cover 23 + * every path? (Hint: check line 45) 3 24 * 4 - * Typical fetch implementation with retry and timeout. 5 - * Read through and spot the gotchas - there are a few. 25 + * Prerequisites: Promise understanding 26 + * Next: Compare with with-purus.ts to see the improvement 6 27 */ 7 28 8 29 type User = { ··· 11 32 email: string 12 33 } 13 34 14 - // ----------------------------------------------------------------------------- 15 - // Fetch with retry and timeout 16 - // ----------------------------------------------------------------------------- 35 + // ============================================================================= 36 + // SECTION 1: Fetch with Retry and Timeout 37 + // ============================================================================= 38 + // 39 + // COMPLEXITY EXPLOSION: 40 + // What starts as a simple fetch() quickly becomes a maze of: 41 + // - Manual AbortController creation 42 + // - Multiple clearTimeout calls (easy to miss one!) 43 + // - Exponential backoff calculation inline 44 + // - Error message sniffing to detect timeout vs network errors 45 + // 46 + // GOTCHA #1: Line 66 - clearTimeout is in the catch block, but line 58 47 + // throws without clearing the timeout first. Memory leak potential! 48 + // 49 + // GOTCHA #2: lastError is `unknown` - we've lost all type information 50 + // about what actually went wrong. 51 + // ============================================================================= 17 52 18 53 const fetchWithRetryAndTimeout = async ( 19 54 url: string, ··· 24 59 } = {} 25 60 ): Promise<User> => { 26 61 const { retries = 3, timeoutMs = 5000, retryDelayMs = 1000 } = options 27 - let lastError: unknown 62 + let lastError: unknown // <- We've lost all error type information here 28 63 29 64 for (let attempt = 0; attempt <= retries; attempt++) { 30 65 const controller = new AbortController() ··· 41 76 clearTimeout(timeoutId) // Don't forget this! 42 77 43 78 if (!response.ok) { 44 - // Whoops - did you remember clearTimeout before throwing? 79 + // GOTCHA: We cleared the timeout above, but if we restructure this code 80 + // and move the throw before clearTimeout, we leak the timer. 81 + // This kind of subtle ordering bug is why cleanup should be automatic. 45 82 throw new Error(`HTTP ${response.status}: ${response.statusText}`) 46 83 } 47 84 ··· 50 87 return data as User 51 88 52 89 } catch (e: unknown) { 53 - // Need to clean up here too - easy to miss 90 + // Need to clean up here too - easy to miss in a refactor 54 91 if (timeoutId) clearTimeout(timeoutId) 55 92 56 - lastError = e // e is unknown - we'll deal with it later... somehow 93 + lastError = e // e is unknown - good luck figuring out what went wrong 57 94 95 + // MESSAGE SNIFFING: We detect timeout by checking error.name === "AbortError" 96 + // This is fragile - browser implementations could differ 58 97 const isTimeout = e instanceof Error && e.name === "AbortError" 59 98 const message = e instanceof Error ? e.message : "Unknown error" 60 99 ··· 68 107 } 69 108 } 70 109 71 - // Caller gets unknown - good luck figuring out what went wrong 110 + // Caller gets unknown - they have to do the same instanceof/message checking 72 111 throw lastError 73 112 } 74 113 75 - // ----------------------------------------------------------------------------- 76 - // Demo 77 - // ----------------------------------------------------------------------------- 114 + // ============================================================================= 115 + // SECTION 2: Demo 116 + // ============================================================================= 117 + // 118 + // NOTICE THE PATTERN: 119 + // Every test is wrapped in try/catch. Every catch block: 120 + // 1. Receives `e: unknown` 121 + // 2. Does instanceof Error check 122 + // 3. Extracts .message 123 + // 4. Has no compile-time guarantees about what errors are possible 124 + // 125 + // If fetchWithRetryAndTimeout starts throwing a new error type, these catch 126 + // blocks silently do the wrong thing. No compiler warning. 127 + // ============================================================================= 78 128 79 129 const main = async () => { 80 130 console.log("=== HTTP Client (without purus) ===\n") ··· 88 138 console.log(`Got user: ${user.name} (${user.email})\n`) 89 139 90 140 } catch (e: unknown) { 91 - // What did we catch? Network error? Timeout? 404? 500? 92 - // TypeScript shrugs. We have to guess. 141 + // WHAT DID WE CATCH? 142 + // - Network error? Timeout? 404? 500? JSON parse error? 143 + // - TypeScript has no idea. We're on our own. 93 144 console.error("Failed:", e instanceof Error ? e.message : e) 94 145 } 95 146 ··· 102 153 console.log(`Got user: ${user.name}\n`) 103 154 104 155 } catch (e: unknown) { 156 + // We HOPE this is a timeout error, but TypeScript can't verify 105 157 console.log("Expected timeout:", e instanceof Error ? e.message : e) 106 158 console.log() 107 159 } ··· 115 167 console.log(`Got user: ${user.name}\n`) 116 168 117 169 } catch (e: unknown) { 170 + // We HOPE this is a 404 error, but it could be anything 118 171 console.log("Expected 404:", e instanceof Error ? e.message : e) 119 172 console.log() 120 173 }