···11/**
22- * HTTP Client - with purus
22+ * HTTP Client Example - Building Resilient Data Fetching
33+ * ======================================================
44+ *
55+ * This example teaches three core purus concepts:
66+ *
77+ * 1. TYPED ERRORS - Know exactly what can fail (no `catch (e: unknown)`)
88+ * The HttpError union has specific variants, so match() forces you to
99+ * handle each case. Try commenting out a case - the compiler stops you.
1010+ *
1111+ * 2. COMPOSABLE OPERATIONS - pipe(), retry(), timeout() chain cleanly
1212+ * Each combinator is a pure function that transforms an Eff. You can
1313+ * add/remove/reorder them without restructuring your code.
1414+ *
1515+ * 3. AUTOMATIC CLEANUP - Return a cleanup function, never forget to clearTimeout
1616+ * The async() constructor takes a register function that returns a cleanup.
1717+ * When the effect is cancelled (timeout, interrupt), cleanup runs automatically.
318 *
44- * Same fetch + retry + timeout, but:
55- * - Errors are typed (no more `unknown`)
66- * - Cleanup happens automatically
77- * - Retry/timeout are just composable functions
1919+ * Compare with without-purus.ts to see how vanilla TypeScript handles the same
2020+ * problems (spoiler: lots of manual AbortController wiring and `e: unknown`).
2121+ *
2222+ * Prerequisites: Basic Promise understanding
2323+ * Next: workflow-engine for branded types and typestate
824 */
9251026import {
···2238 runPromise,
2339} from "../../src/index"
24402525-// -----------------------------------------------------------------------------
2626-// Error types - the compiler knows exactly what can fail
2727-// -----------------------------------------------------------------------------
4141+// =============================================================================
4242+// SECTION 1: Error Types
4343+// =============================================================================
4444+//
4545+// WHY TYPED ERRORS MATTER:
4646+// In vanilla JS, errors are `unknown` in catch blocks. You end up with:
4747+// catch (e) { if (e instanceof Error && e.message.includes("timeout")) ... }
4848+//
4949+// With discriminated unions, each error variant is explicit in the type.
5050+// The match() function forces exhaustive handling - forget a case = compile error.
5151+//
5252+// PATTERN: Each error variant has a _tag (discriminant) and relevant data.
5353+// This is the standard discriminated union pattern in TypeScript.
5454+// =============================================================================
28552956type HttpError =
3057 | { readonly _tag: "NetworkError"; readonly message: string }
···3259 | { readonly _tag: "NotFound"; readonly url: string }
3360 | { readonly _tag: "ServerError"; readonly status: number }
34616262+// Smart constructors - these ensure consistent error creation
3563const HttpError = {
3664 network: (message: string): HttpError => ({ _tag: "NetworkError", message }),
3765 timeout: (ms: number): HttpError => ({ _tag: "TimeoutError", ms }),
···4573 email: string
4674}
47754848-// -----------------------------------------------------------------------------
4949-// Fetch as an effect - cleanup is handled by the runtime
5050-// -----------------------------------------------------------------------------
7676+// =============================================================================
7777+// SECTION 2: Fetch as an Effect
7878+// =============================================================================
7979+//
8080+// WHY USE async() INSTEAD OF fromPromise():
8181+// The async() constructor gives you control over cleanup. When an effect is
8282+// cancelled (via timeout, race, or manual interrupt), the cleanup function runs.
8383+//
8484+// HOW IT WORKS:
8585+// 1. async() takes a "register" function
8686+// 2. The register function receives a "resume" callback
8787+// 3. You start your async work and call resume(Exit.succeed(value)) or resume(Exit.fail(error))
8888+// 4. Return a cleanup function - it runs on cancellation
8989+//
9090+// GOTCHA: The AbortController abort() must happen in the cleanup function.
9191+// If you forget to return cleanup, the request keeps running even after timeout!
9292+// =============================================================================
51935294const fetchUser = (url: string): Eff<User, HttpError, unknown> =>
5395 async((resume) => {
9696+ // Create an AbortController - we'll abort it on cleanup
5497 const controller = new AbortController()
55985699 console.log(`[Fetch] ${url}`)
···67110 }
68111 })
69112 .catch((err) => {
7070- if (err.name === "AbortError") return // Interrupted, cleanup will run
113113+ // GOTCHA: Don't resume on AbortError - the fiber is already cancelled
114114+ // Resuming after cancellation would cause undefined behavior
115115+ if (err.name === "AbortError") return
71116 resume(Exit.fail(HttpError.network(err.message)))
72117 })
731187474- // Return cleanup - called automatically on timeout or interruption
119119+ // THE KEY INSIGHT: This cleanup function runs automatically on timeout/interrupt
120120+ // No manual finally blocks, no forgetting to clearTimeout, no leaked requests
75121 return () => {
76122 console.log("[Cleanup] Aborting request")
77123 controller.abort()
78124 }
79125 })
801268181-// -----------------------------------------------------------------------------
8282-// Error handling - try removing a case, TypeScript will complain
8383-// -----------------------------------------------------------------------------
127127+// =============================================================================
128128+// SECTION 3: Error Handling with match()
129129+// =============================================================================
130130+//
131131+// WHY match() OVER switch/if-else:
132132+// - match() is EXHAUSTIVE - add a new error variant, and all match() calls
133133+// that don't handle it become compile errors
134134+// - It's an expression, not a statement, so it always returns a value
135135+// - Each handler receives the correctly narrowed type (e.g., { _tag: "TimeoutError", ms })
136136+//
137137+// TRY THIS: Comment out one of the cases below. TypeScript will error.
138138+// =============================================================================
8413985140const handleError = (error: HttpError): string =>
86141 match(error)({
···90145 ServerError: ({ status }) => `Server error: ${status}`,
91146 })
921479393-// -----------------------------------------------------------------------------
9494-// Demo
9595-// -----------------------------------------------------------------------------
148148+// =============================================================================
149149+// SECTION 4: Demo
150150+// =============================================================================
151151+//
152152+// COMPOSABILITY IN ACTION:
153153+// Notice how each test builds up functionality using pipe():
154154+// - fetchUser() is the base effect
155155+// - retry(n) wraps it with retry logic
156156+// - timeout(ms) adds a timeout
157157+// - catchAll() recovers from errors
158158+//
159159+// Each combinator is independent - you can add, remove, or reorder them.
160160+// Compare this to the nested try/catch/finally in without-purus.ts.
161161+// =============================================================================
9616297163const main = async () => {
98164 console.log("=== HTTP Client (with purus) ===\n")
99165166166+ // ---------------------------------------------------------------------------
100167 // Test 1: Successful request
168168+ // Shows: basic pipe with retry + timeout, happy path
169169+ // ---------------------------------------------------------------------------
101170 console.log("--- Test 1: Successful request ---")
102171103172 const request1 = pipe(
104173 fetchUser("https://jsonplaceholder.typicode.com/users/1"),
105105- retry(2),
106106- timeout(5000),
174174+ retry(2), // Retry up to 2 times on failure
175175+ timeout(5000), // Cancel if not done in 5s
107176 flatMap((result) =>
177177+ // timeout() returns null on timeout, convert to typed error
108178 result === null
109179 ? fail(HttpError.timeout(5000))
110180 : succeed(result)
···118188 console.log(`Error: ${handleError(e as HttpError)}\n`)
119189 }
120190191191+ // ---------------------------------------------------------------------------
121192 // Test 2: Short timeout - will fail, but we recover
193193+ // Shows: catchAll() for error recovery, returns fallback value
194194+ // ---------------------------------------------------------------------------
122195 console.log("--- Test 2: Short timeout ---")
123196124197 const request2 = pipe(
125198 fetchUser("https://jsonplaceholder.typicode.com/users/1"),
126126- timeout(1),
199199+ timeout(1), // 1ms timeout = guaranteed timeout
127200 flatMap((result) =>
128201 result === null
129202 ? fail(HttpError.timeout(1))
130203 : succeed(result)
131204 ),
205205+ // RECOVERY: catchAll transforms errors into success values
206206+ // The error is typed, so we know exactly what we're catching
132207 catchAll((error) => {
133208 console.log(`[Caught] ${handleError(error)}`)
134209 return succeed({ id: 0, name: "Timeout Fallback", email: "" })
···138213 const fallbackUser = await runPromise(request2)
139214 console.log(`Result: ${fallbackUser.name}\n`)
140215216216+ // ---------------------------------------------------------------------------
141217 // Test 3: 404 error - recover with default
218218+ // Shows: server-side errors flow through typed error channel
219219+ // ---------------------------------------------------------------------------
142220 console.log("--- Test 3: 404 Not Found ---")
143221144222 const request3 = pipe(
145223 fetchUser("https://jsonplaceholder.typicode.com/users/99999"),
146146- retry(0),
224224+ retry(0), // No retries for this test
147225 catchAll((error) => {
148226 console.log(`[Caught] ${handleError(error)}`)
149227 return succeed({ id: 0, name: "Default User", email: "" })
+69-16
examples/http-client/without-purus.ts
···11/**
22- * HTTP Client - vanilla TypeScript
22+ * HTTP Client - Vanilla TypeScript (Comparison)
33+ * ==============================================
44+ *
55+ * This is the "before" version. Compare with with-purus.ts to see how purus
66+ * improves resilient HTTP fetching.
77+ *
88+ * PROBLEMS THIS APPROACH HAS:
99+ *
1010+ * 1. ERRORS ARE UNKNOWN - Every catch block has `e: unknown`. You have to
1111+ * instanceof check and message-sniff to figure out what happened.
1212+ * What if someone changes the error message? Your handling breaks.
1313+ *
1414+ * 2. MANUAL CLEANUP - AbortController and setTimeout need careful wiring.
1515+ * Notice how many places we call clearTimeout()? Miss one = memory leak.
1616+ * The early return on line 45 doesn't clear the timeout before throwing.
1717+ *
1818+ * 3. RETRY IS BAKED IN - The retry logic is tangled into fetchWithRetryAndTimeout.
1919+ * Want retry without timeout? Want timeout without retry? You need separate
2020+ * functions or complex option objects.
2121+ *
2222+ * Read through and count the clearTimeout() calls. Ask yourself: did we cover
2323+ * every path? (Hint: check line 45)
324 *
44- * Typical fetch implementation with retry and timeout.
55- * Read through and spot the gotchas - there are a few.
2525+ * Prerequisites: Promise understanding
2626+ * Next: Compare with with-purus.ts to see the improvement
627 */
728829type User = {
···1132 email: string
1233}
13341414-// -----------------------------------------------------------------------------
1515-// Fetch with retry and timeout
1616-// -----------------------------------------------------------------------------
3535+// =============================================================================
3636+// SECTION 1: Fetch with Retry and Timeout
3737+// =============================================================================
3838+//
3939+// COMPLEXITY EXPLOSION:
4040+// What starts as a simple fetch() quickly becomes a maze of:
4141+// - Manual AbortController creation
4242+// - Multiple clearTimeout calls (easy to miss one!)
4343+// - Exponential backoff calculation inline
4444+// - Error message sniffing to detect timeout vs network errors
4545+//
4646+// GOTCHA #1: Line 66 - clearTimeout is in the catch block, but line 58
4747+// throws without clearing the timeout first. Memory leak potential!
4848+//
4949+// GOTCHA #2: lastError is `unknown` - we've lost all type information
5050+// about what actually went wrong.
5151+// =============================================================================
17521853const fetchWithRetryAndTimeout = async (
1954 url: string,
···2459 } = {}
2560): Promise<User> => {
2661 const { retries = 3, timeoutMs = 5000, retryDelayMs = 1000 } = options
2727- let lastError: unknown
6262+ let lastError: unknown // <- We've lost all error type information here
28632964 for (let attempt = 0; attempt <= retries; attempt++) {
3065 const controller = new AbortController()
···4176 clearTimeout(timeoutId) // Don't forget this!
42774378 if (!response.ok) {
4444- // Whoops - did you remember clearTimeout before throwing?
7979+ // GOTCHA: We cleared the timeout above, but if we restructure this code
8080+ // and move the throw before clearTimeout, we leak the timer.
8181+ // This kind of subtle ordering bug is why cleanup should be automatic.
4582 throw new Error(`HTTP ${response.status}: ${response.statusText}`)
4683 }
4784···5087 return data as User
51885289 } catch (e: unknown) {
5353- // Need to clean up here too - easy to miss
9090+ // Need to clean up here too - easy to miss in a refactor
5491 if (timeoutId) clearTimeout(timeoutId)
55925656- lastError = e // e is unknown - we'll deal with it later... somehow
9393+ lastError = e // e is unknown - good luck figuring out what went wrong
57949595+ // MESSAGE SNIFFING: We detect timeout by checking error.name === "AbortError"
9696+ // This is fragile - browser implementations could differ
5897 const isTimeout = e instanceof Error && e.name === "AbortError"
5998 const message = e instanceof Error ? e.message : "Unknown error"
6099···68107 }
69108 }
701097171- // Caller gets unknown - good luck figuring out what went wrong
110110+ // Caller gets unknown - they have to do the same instanceof/message checking
72111 throw lastError
73112}
741137575-// -----------------------------------------------------------------------------
7676-// Demo
7777-// -----------------------------------------------------------------------------
114114+// =============================================================================
115115+// SECTION 2: Demo
116116+// =============================================================================
117117+//
118118+// NOTICE THE PATTERN:
119119+// Every test is wrapped in try/catch. Every catch block:
120120+// 1. Receives `e: unknown`
121121+// 2. Does instanceof Error check
122122+// 3. Extracts .message
123123+// 4. Has no compile-time guarantees about what errors are possible
124124+//
125125+// If fetchWithRetryAndTimeout starts throwing a new error type, these catch
126126+// blocks silently do the wrong thing. No compiler warning.
127127+// =============================================================================
7812879129const main = async () => {
80130 console.log("=== HTTP Client (without purus) ===\n")
···88138 console.log(`Got user: ${user.name} (${user.email})\n`)
8913990140 } catch (e: unknown) {
9191- // What did we catch? Network error? Timeout? 404? 500?
9292- // TypeScript shrugs. We have to guess.
141141+ // WHAT DID WE CATCH?
142142+ // - Network error? Timeout? 404? 500? JSON parse error?
143143+ // - TypeScript has no idea. We're on our own.
93144 console.error("Failed:", e instanceof Error ? e.message : e)
94145 }
95146···102153 console.log(`Got user: ${user.name}\n`)
103154104155 } catch (e: unknown) {
156156+ // We HOPE this is a timeout error, but TypeScript can't verify
105157 console.log("Expected timeout:", e instanceof Error ? e.message : e)
106158 console.log()
107159 }
···115167 console.log(`Got user: ${user.name}\n`)
116168117169 } catch (e: unknown) {
170170+ // We HOPE this is a 404 error, but it could be anything
118171 console.log("Expected 404:", e instanceof Error ? e.message : e)
119172 console.log()
120173 }