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 concept article: Why Errors as Values?

+399
+399
docs/guides/concepts/errors-as-values.md
··· 1 + # Why Errors as Values? 2 + 3 + This article explains the fundamental shift from throwing exceptions to returning typed errors, and why this change makes your TypeScript code more reliable. 4 + 5 + --- 6 + 7 + ## The Exception Problem 8 + 9 + TypeScript has a powerful type system. You define interfaces, use generics, and the compiler catches type errors before your code runs. It's a safety net that prevents entire categories of bugs. 10 + 11 + Except for exceptions. 12 + 13 + ```typescript 14 + const fetchUser = async (id: string): Promise<User> => { 15 + const response = await fetch(`/users/${id}`) 16 + if (!response.ok) { 17 + throw new Error(`HTTP ${response.status}`) 18 + } 19 + return response.json() 20 + } 21 + ``` 22 + 23 + The return type says `Promise<User>`. But this function can also throw. What can it throw? The type system doesn't say. Could be: 24 + - Network error (fetch failed) 25 + - HTTP error (we throw it explicitly) 26 + - JSON parse error (response.json() failed) 27 + - Something else entirely 28 + 29 + Now look at the caller: 30 + 31 + ```typescript 32 + try { 33 + const user = await fetchUser("123") 34 + console.log(user.name) 35 + } catch (e) { 36 + // What is e? 37 + } 38 + ``` 39 + 40 + The variable `e` has type `unknown`. TypeScript has given up. All the careful type work you did is gone the moment an exception is thrown. 41 + 42 + ### The Real Cost 43 + 44 + This isn't just an academic problem. In real codebases: 45 + 46 + 1. **Errors are invisible** - Function signatures don't mention what can fail 47 + 2. **Handling is guesswork** - You catch `unknown` and hope for the best 48 + 3. **Refactoring is dangerous** - Change what a function throws, callers don't know 49 + 4. **Testing is incomplete** - Hard to verify you handle all error cases 50 + 51 + Consider a chain of function calls: 52 + 53 + ```typescript 54 + const processOrder = async (orderId: string) => { 55 + const order = await fetchOrder(orderId) // Can throw A, B 56 + const user = await fetchUser(order.userId) // Can throw C, D 57 + await chargePayment(user, order.total) // Can throw E, F, G 58 + await sendEmail(user.email, "Confirmed") // Can throw H 59 + return order 60 + } 61 + ``` 62 + 63 + What can `processOrder` throw? Everything from A to H. But the type signature just says `Promise<Order>`. Anyone calling this function has no idea what might go wrong. 64 + 65 + --- 66 + 67 + ## Railway Oriented Programming 68 + 69 + Imagine your code as a railway track. The happy path is one track - data flows through transformations until you reach the destination. 70 + 71 + ``` 72 + [Input] → [Transform A] → [Transform B] → [Transform C] → [Output] 73 + ``` 74 + 75 + When something fails, you need to switch tracks: 76 + 77 + ``` 78 + [Input] → [Transform A] → [Transform B] ─┬→ [Transform C] → [Output] 79 + 80 + └→ [Error Track] → [Error Output] 81 + ``` 82 + 83 + This is **Railway Oriented Programming**. Instead of throwing to escape the happy path, you return a value that can be either success or failure. The type system tracks both tracks. 84 + 85 + In purus, this is the `Result<T, E>` type: 86 + 87 + ```typescript 88 + type Result<T, E> = 89 + | { _tag: "Ok"; value: T } // Success track 90 + | { _tag: "Err"; error: E } // Error track 91 + ``` 92 + 93 + Every function that can fail returns `Result`. The caller sees both possibilities in the type. 94 + 95 + --- 96 + 97 + ## The Result Pattern 98 + 99 + ### Creating Results 100 + 101 + ```typescript 102 + import { ok, err } from "purus-ts" 103 + 104 + // Success 105 + const success = ok(42) 106 + // Type: Ok<number> 107 + 108 + // Failure 109 + const failure = err({ _tag: "NotFound", id: "123" }) 110 + // Type: Err<{ _tag: "NotFound"; id: string }> 111 + ``` 112 + 113 + ### Checking Results 114 + 115 + ```typescript 116 + import { isOk, isErr } from "purus-ts" 117 + 118 + const result: Result<number, MyError> = someFunction() 119 + 120 + if (isOk(result)) { 121 + console.log(result.value) // TypeScript knows it's Ok 122 + } else { 123 + console.log(result.error) // TypeScript knows it's Err 124 + } 125 + ``` 126 + 127 + ### Pattern Matching 128 + 129 + The `matchResult` function ensures you handle both cases: 130 + 131 + ```typescript 132 + import { matchResult } from "purus-ts" 133 + 134 + const message = matchResult( 135 + value => `Success: ${value}`, 136 + error => `Error: ${error._tag}` 137 + )(result) 138 + ``` 139 + 140 + If you forget to handle one case, TypeScript complains. 141 + 142 + --- 143 + 144 + ## Error Composition 145 + 146 + The real power of Result is composition. Errors flow through your pipeline automatically. 147 + 148 + ### mapResult - Transform success values 149 + 150 + ```typescript 151 + import { ok, mapResult, pipe } from "purus-ts" 152 + 153 + const result = pipe( 154 + ok(10), 155 + mapResult(n => n * 2), 156 + mapResult(n => n.toString()) 157 + ) 158 + // Result: Ok("20") 159 + ``` 160 + 161 + If the input is `Err`, `mapResult` does nothing - the error passes through unchanged. 162 + 163 + ### chainResult - Chain operations that can fail 164 + 165 + ```typescript 166 + import { ok, err, chainResult, pipe } from "purus-ts" 167 + 168 + const divide = (a: number, b: number): Result<number, string> => 169 + b === 0 ? err("Division by zero") : ok(a / b) 170 + 171 + const result = pipe( 172 + ok(100), 173 + chainResult(n => divide(n, 5)), // Ok(20) 174 + chainResult(n => divide(n, 0)), // Err("Division by zero") 175 + chainResult(n => divide(n, 2)) // Never runs - already Err 176 + ) 177 + // Result: Err("Division by zero") 178 + ``` 179 + 180 + Once you're on the error track, you stay there. No more transformations run. 181 + 182 + ### unwrapOr - Get the value with a default 183 + 184 + ```typescript 185 + import { ok, err, unwrapOr, pipe } from "purus-ts" 186 + 187 + const value1 = pipe(ok(42), unwrapOr(0)) // 42 188 + const value2 = pipe(err("oops"), unwrapOr(0)) // 0 189 + ``` 190 + 191 + ### mapErr - Transform error values 192 + 193 + ```typescript 194 + import { err, mapErr, pipe } from "purus-ts" 195 + 196 + const result = pipe( 197 + err("raw error"), 198 + mapErr(msg => ({ _tag: "FormattedError" as const, message: msg })) 199 + ) 200 + // Result: Err({ _tag: "FormattedError", message: "raw error" }) 201 + ``` 202 + 203 + Useful for converting between error types at module boundaries. 204 + 205 + --- 206 + 207 + ## Real-World Example: API Client 208 + 209 + Let's build a typed API client that makes errors explicit. 210 + 211 + ### Define Error Types 212 + 213 + ```typescript 214 + type ApiError = 215 + | { _tag: "NetworkError"; message: string } 216 + | { _tag: "NotFound"; resource: string; id: string } 217 + | { _tag: "Unauthorized" } 218 + | { _tag: "ValidationError"; fields: string[] } 219 + | { _tag: "ServerError"; status: number } 220 + ``` 221 + 222 + Each error variant has a `_tag` for pattern matching and relevant data for debugging. 223 + 224 + ### Create the Fetch Wrapper 225 + 226 + ```typescript 227 + import { type Result, ok, err } from "purus-ts" 228 + 229 + const apiFetch = async <T>( 230 + url: string 231 + ): Promise<Result<T, ApiError>> => { 232 + try { 233 + const response = await fetch(url) 234 + 235 + if (response.status === 401) { 236 + return err({ _tag: "Unauthorized" }) 237 + } 238 + 239 + if (response.status === 404) { 240 + return err({ _tag: "NotFound", resource: url, id: "" }) 241 + } 242 + 243 + if (response.status === 422) { 244 + const body = await response.json() 245 + return err({ _tag: "ValidationError", fields: body.fields || [] }) 246 + } 247 + 248 + if (!response.ok) { 249 + return err({ _tag: "ServerError", status: response.status }) 250 + } 251 + 252 + const data = await response.json() 253 + return ok(data as T) 254 + 255 + } catch (e) { 256 + return err({ 257 + _tag: "NetworkError", 258 + message: e instanceof Error ? e.message : "Unknown error" 259 + }) 260 + } 261 + } 262 + ``` 263 + 264 + ### Use It 265 + 266 + ```typescript 267 + import { matchResult, pipe, chainResult, mapResult } from "purus-ts" 268 + 269 + type User = { id: string; name: string; email: string } 270 + type Order = { id: string; userId: string; total: number } 271 + 272 + const getUser = (id: string) => apiFetch<User>(`/users/${id}`) 273 + const getOrders = (userId: string) => apiFetch<Order[]>(`/users/${userId}/orders`) 274 + 275 + const getUserWithOrders = async (userId: string) => { 276 + const userResult = await getUser(userId) 277 + 278 + return matchResult( 279 + async (user) => { 280 + const ordersResult = await getOrders(user.id) 281 + return matchResult( 282 + orders => ok({ user, orders }), 283 + error => err(error) 284 + )(ordersResult) 285 + }, 286 + async (error) => err(error) 287 + )(userResult) 288 + } 289 + ``` 290 + 291 + Now callers of `getUserWithOrders` know exactly what can fail: 292 + 293 + ```typescript 294 + const result = await getUserWithOrders("123") 295 + 296 + matchResult( 297 + ({ user, orders }) => { 298 + console.log(`${user.name} has ${orders.length} orders`) 299 + }, 300 + error => { 301 + // TypeScript knows error is ApiError 302 + switch (error._tag) { 303 + case "NetworkError": 304 + console.error("Network issue:", error.message) 305 + break 306 + case "NotFound": 307 + console.error("User not found") 308 + break 309 + case "Unauthorized": 310 + console.error("Please log in") 311 + break 312 + case "ValidationError": 313 + console.error("Invalid fields:", error.fields) 314 + break 315 + case "ServerError": 316 + console.error("Server error:", error.status) 317 + break 318 + } 319 + } 320 + )(result) 321 + ``` 322 + 323 + ### Benefits 324 + 325 + 1. **Complete type information** - The signature tells you everything that can fail 326 + 2. **Exhaustive handling** - TypeScript ensures you handle all error cases 327 + 3. **Structured errors** - Each error has relevant context, not just a string 328 + 4. **Composable** - Chain operations and errors flow automatically 329 + 330 + --- 331 + 332 + ## When to Still Throw 333 + 334 + Errors as values aren't for everything. Exceptions are still appropriate for: 335 + 336 + ### Programmer Errors 337 + 338 + Bugs that should never happen in correct code: 339 + 340 + ```typescript 341 + const assertNonEmpty = <T>(arr: T[]): T[] => { 342 + if (arr.length === 0) { 343 + throw new Error("Assertion failed: array is empty") 344 + } 345 + return arr 346 + } 347 + ``` 348 + 349 + These indicate bugs, not expected failures. Crashing loudly is the right response. 350 + 351 + ### Truly Exceptional Conditions 352 + 353 + System-level failures that can't be meaningfully handled: 354 + 355 + - Out of memory 356 + - Stack overflow 357 + - File system corruption 358 + 359 + ### Quick Scripts 360 + 361 + For throwaway scripts where error handling doesn't matter: 362 + 363 + ```typescript 364 + // Quick script - exceptions are fine 365 + const data = JSON.parse(fs.readFileSync("config.json", "utf-8")) 366 + ``` 367 + 368 + ### The Boundary 369 + 370 + Use `tryCatch` to convert exceptions to Results at the boundary: 371 + 372 + ```typescript 373 + import { tryCatch } from "purus-ts" 374 + 375 + const parseJson = (input: string): Result<unknown, unknown> => 376 + tryCatch(() => JSON.parse(input)) 377 + ``` 378 + 379 + This lets you use exception-based libraries while keeping your code Result-based. 380 + 381 + --- 382 + 383 + ## Key Takeaways 384 + 385 + 1. **Exceptions break types** - `catch (e: unknown)` loses all error information 386 + 2. **Result makes errors visible** - The return type shows what can fail 387 + 3. **Composition handles errors automatically** - Use `chainResult`, errors flow through 388 + 4. **Pattern matching is exhaustive** - Add a new error, compiler tells you where to handle it 389 + 5. **Use exceptions for bugs** - Programmer errors should crash, not return Result 390 + 391 + The shift from exceptions to errors as values is the foundation of reliable TypeScript. Once you see errors in the type system, you can't unsee the blind spots that exceptions create. 392 + 393 + --- 394 + 395 + ## See Also 396 + 397 + - [Tutorial Chapter 1: Why Functional TypeScript?](../tutorial/01-why-functional-typescript.md) - Introduction with examples 398 + - [Tutorial Chapter 3: Typed Errors with Result](../tutorial/03-typed-errors-with-result.md) - Hands-on practice 399 + - [Branded Types In Depth](./branded-types.md) - Another compile-time safety technique