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 http-client example with side-by-side comparison

+361
+44
examples/http-client/README.md
··· 1 + # HTTP Client 2 + 3 + > Fetching data with retry and timeout shouldn't require 50 lines of boilerplate. 4 + 5 + ## The Problem 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 12 + 13 + In vanilla TypeScript, this turns into a mess of nested try/catch, manual AbortController management, and `catch (e: unknown)` everywhere. 14 + 15 + ## Run Both Versions 16 + 17 + ```bash 18 + bun run examples/http-client/without-purus.ts 19 + bun run examples/http-client/with-purus.ts 20 + ``` 21 + 22 + ## Without purus 23 + 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 30 + 31 + ## With purus 32 + 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 38 + 39 + ## Key Takeaways 40 + 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
+181
examples/http-client/with-purus.ts
··· 1 + /** 2 + * HTTP Client - WITH purus 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 8 + */ 9 + 10 + import { 11 + // Types 12 + type Eff, 13 + Exit, 14 + 15 + // Constructors 16 + succeed, 17 + fail, 18 + async, 19 + 20 + // Transformations 21 + flatMap, 22 + catchAll, 23 + 24 + // Combinators 25 + timeout, 26 + retry, 27 + 28 + // Composition 29 + pipe, 30 + match, 31 + 32 + // Runners 33 + runPromise, 34 + } from "../../src/index" 35 + 36 + // ----------------------------------------------------------------------------- 37 + // SOLUTION: Typed error union - compiler knows ALL possible failures 38 + // ----------------------------------------------------------------------------- 39 + 40 + type HttpError = 41 + | { readonly _tag: "NetworkError"; readonly message: string } 42 + | { readonly _tag: "TimeoutError"; readonly ms: number } 43 + | { readonly _tag: "NotFound"; readonly url: string } 44 + | { readonly _tag: "ServerError"; readonly status: number } 45 + 46 + // SOLUTION: Smart constructors make creating errors clean 47 + const HttpError = { 48 + network: (message: string): HttpError => ({ _tag: "NetworkError", message }), 49 + timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }), 50 + notFound: (url: string): HttpError => ({ _tag: "NotFound", url }), 51 + serverError: (status: number): HttpError => ({ _tag: "ServerError", status }), 52 + } 53 + 54 + type User = { 55 + id: number 56 + name: string 57 + email: string 58 + } 59 + 60 + // ----------------------------------------------------------------------------- 61 + // SOLUTION: Fetch as an effect with automatic abort cleanup 62 + // ----------------------------------------------------------------------------- 63 + 64 + const fetchUser = (url: string): Eff<User, HttpError, unknown> => 65 + async((resume) => { 66 + // SOLUTION: AbortController is managed automatically 67 + const controller = new AbortController() 68 + 69 + console.log(`[Fetch] ${url}`) 70 + 71 + fetch(url, { signal: controller.signal }) 72 + .then(async (response) => { 73 + if (response.ok) { 74 + const data = await response.json() 75 + resume(Exit.succeed(data as User)) 76 + } else if (response.status === 404) { 77 + resume(Exit.fail(HttpError.notFound(url))) 78 + } else { 79 + resume(Exit.fail(HttpError.serverError(response.status))) 80 + } 81 + }) 82 + .catch((err) => { 83 + if (err.name === "AbortError") { 84 + // SOLUTION: Interrupted - cleanup runs automatically, no resume needed 85 + return 86 + } 87 + resume(Exit.fail(HttpError.network(err.message))) 88 + }) 89 + 90 + // SOLUTION: Return cleanup function - runtime calls it on interrupt! 91 + return () => { 92 + console.log("[Cleanup] Aborting request") 93 + controller.abort() 94 + } 95 + }) 96 + 97 + // ----------------------------------------------------------------------------- 98 + // SOLUTION: Pattern matching - compiler forces handling ALL error cases 99 + // ----------------------------------------------------------------------------- 100 + 101 + const handleError = (error: HttpError): string => 102 + match(error)({ 103 + // SOLUTION: Try removing one case - TypeScript will error! 104 + NetworkError: ({ message }) => `Network failed: ${message}`, 105 + TimeoutError: ({ ms }) => `Request timed out after ${ms}ms`, 106 + NotFound: ({ url }) => `Resource not found: ${url}`, 107 + ServerError: ({ status }) => `Server error: ${status}`, 108 + }) 109 + 110 + // ----------------------------------------------------------------------------- 111 + // Demo - run it 112 + // ----------------------------------------------------------------------------- 113 + 114 + async function main() { 115 + console.log("=== HTTP Client (with purus) ===\n") 116 + 117 + // Test 1: Successful request with retry + timeout 118 + console.log("--- Test 1: Successful request ---") 119 + 120 + // SOLUTION: Composable one-liner! retry(2) + timeout(5000) 121 + const request1 = pipe( 122 + fetchUser("https://jsonplaceholder.typicode.com/users/1"), 123 + retry(2), 124 + timeout(5000), 125 + flatMap((result) => 126 + result === null 127 + ? fail(HttpError.timeout(5000)) 128 + : succeed(result) 129 + ) 130 + ) 131 + 132 + try { 133 + const user = await runPromise(request1) 134 + console.log(`Got user: ${user.name} (${user.email})\n`) 135 + } catch (e) { 136 + console.log(`Error: ${handleError(e as HttpError)}\n`) 137 + } 138 + 139 + // Test 2: Short timeout 140 + console.log("--- Test 2: Short timeout ---") 141 + 142 + const request2 = pipe( 143 + fetchUser("https://jsonplaceholder.typicode.com/users/1"), 144 + timeout(1), // 1ms - will timeout 145 + flatMap((result) => 146 + result === null 147 + ? fail(HttpError.timeout(1)) 148 + : succeed(result) 149 + ), 150 + // SOLUTION: catchAll provides typed error recovery 151 + catchAll((error) => { 152 + console.log(`[Caught] ${handleError(error)}`) 153 + return succeed({ id: 0, name: "Timeout Fallback", email: "" }) 154 + }) 155 + ) 156 + 157 + const fallbackUser = await runPromise(request2) 158 + console.log(`Result: ${fallbackUser.name}\n`) 159 + 160 + // Test 3: 404 error 161 + console.log("--- Test 3: 404 Not Found ---") 162 + 163 + const request3 = pipe( 164 + fetchUser("https://jsonplaceholder.typicode.com/users/99999"), 165 + retry(0), 166 + catchAll((error) => { 167 + // SOLUTION: error is HttpError, not unknown! 168 + console.log(`[Caught] ${handleError(error)}`) 169 + return succeed({ id: 0, name: "Default User", email: "" }) 170 + }) 171 + ) 172 + 173 + const user404 = await runPromise(request3) 174 + console.log(`Result: ${user404.name}\n`) 175 + 176 + console.log("=== Done ===") 177 + } 178 + 179 + main() 180 + .catch(console.error) 181 + .finally(() => process.exit(0))
+136
examples/http-client/without-purus.ts
··· 1 + /** 2 + * HTTP Client - WITHOUT purus 3 + * 4 + * This is realistic vanilla TypeScript code showing how fetch + retry + timeout 5 + * is typically implemented. Notice the pain points marked with "PROBLEM:". 6 + */ 7 + 8 + type User = { 9 + id: number 10 + name: string 11 + email: string 12 + } 13 + 14 + // ----------------------------------------------------------------------------- 15 + // Fetch with retry and timeout - vanilla implementation 16 + // ----------------------------------------------------------------------------- 17 + 18 + async function fetchWithRetryAndTimeout( 19 + url: string, 20 + options: { 21 + retries?: number 22 + timeoutMs?: number 23 + retryDelayMs?: number 24 + } = {} 25 + ): Promise<User> { 26 + const { retries = 3, timeoutMs = 5000, retryDelayMs = 1000 } = options 27 + let lastError: unknown 28 + 29 + for (let attempt = 0; attempt <= retries; attempt++) { 30 + // PROBLEM: Manual AbortController management - easy to forget cleanup 31 + const controller = new AbortController() 32 + let timeoutId: ReturnType<typeof setTimeout> | undefined 33 + 34 + try { 35 + // PROBLEM: setTimeout + AbortController coordination is tricky 36 + timeoutId = setTimeout(() => controller.abort(), timeoutMs) 37 + 38 + console.log(`[Attempt ${attempt + 1}/${retries + 1}] Fetching ${url}...`) 39 + 40 + const response = await fetch(url, { signal: controller.signal }) 41 + 42 + // PROBLEM: Must remember to clear timeout in success path 43 + clearTimeout(timeoutId) 44 + 45 + if (!response.ok) { 46 + // PROBLEM: What if we forget clearTimeout here? Memory leak! 47 + throw new Error(`HTTP ${response.status}: ${response.statusText}`) 48 + } 49 + 50 + const data = await response.json() 51 + console.log(`[Success] Got response on attempt ${attempt + 1}`) 52 + return data as User 53 + 54 + } catch (e: unknown) { 55 + // PROBLEM: clearTimeout is easy to forget in catch block! 56 + if (timeoutId) clearTimeout(timeoutId) 57 + 58 + // PROBLEM: e is `unknown` - we don't know what errors can occur 59 + lastError = e 60 + 61 + const isTimeout = e instanceof Error && e.name === "AbortError" 62 + const message = e instanceof Error ? e.message : "Unknown error" 63 + 64 + console.log(`[Attempt ${attempt + 1}] Failed: ${isTimeout ? "Timeout" : message}`) 65 + 66 + // PROBLEM: Retry logic is scattered and mixed with error handling 67 + if (attempt < retries) { 68 + const delay = retryDelayMs * Math.pow(2, attempt) // Exponential backoff 69 + console.log(`[Retry] Waiting ${delay}ms before retry...`) 70 + await new Promise(resolve => setTimeout(resolve, delay)) 71 + } 72 + } 73 + } 74 + 75 + // PROBLEM: We throw `unknown` - caller has no idea what to catch 76 + throw lastError 77 + } 78 + 79 + // ----------------------------------------------------------------------------- 80 + // Demo - run it 81 + // ----------------------------------------------------------------------------- 82 + 83 + async function main() { 84 + console.log("=== HTTP Client (without purus) ===\n") 85 + 86 + try { 87 + // Test 1: Successful request 88 + console.log("--- Test 1: Successful request ---") 89 + const user = await fetchWithRetryAndTimeout( 90 + "https://jsonplaceholder.typicode.com/users/1", 91 + { retries: 2, timeoutMs: 5000 } 92 + ) 93 + console.log(`Got user: ${user.name} (${user.email})\n`) 94 + 95 + } 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. 98 + console.error("Failed:", e instanceof Error ? e.message : e) 99 + } 100 + 101 + try { 102 + // Test 2: Request with short timeout (will likely timeout) 103 + console.log("--- Test 2: Short timeout ---") 104 + const user = await fetchWithRetryAndTimeout( 105 + "https://jsonplaceholder.typicode.com/users/1", 106 + { retries: 1, timeoutMs: 1 } // 1ms timeout - will fail 107 + ) 108 + console.log(`Got user: ${user.name}\n`) 109 + 110 + } catch (e: unknown) { 111 + // PROBLEM: Same issue - e is unknown, no compiler help 112 + console.log("Expected timeout:", e instanceof Error ? e.message : e) 113 + console.log() 114 + } 115 + 116 + try { 117 + // Test 3: 404 error 118 + console.log("--- Test 3: 404 Not Found ---") 119 + const user = await fetchWithRetryAndTimeout( 120 + "https://jsonplaceholder.typicode.com/users/99999", 121 + { retries: 0, timeoutMs: 5000 } 122 + ) 123 + console.log(`Got user: ${user.name}\n`) 124 + 125 + } catch (e: unknown) { 126 + // PROBLEM: Is this a 404? 500? Network error? We don't know at compile time! 127 + console.log("Expected 404:", e instanceof Error ? e.message : e) 128 + console.log() 129 + } 130 + 131 + console.log("=== Done ===") 132 + } 133 + 134 + main() 135 + .catch(console.error) 136 + .finally(() => process.exit(0))