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 tutorial chapter 1: Why Functional TypeScript?

+246
+246
docs/guides/tutorial/01-why-functional-typescript.md
··· 1 + # Chapter 1: Why Functional TypeScript? 2 + 3 + TypeScript gives you a powerful type system. You define interfaces, catch type errors at compile time, and get autocomplete in your editor. It feels like you're in control. 4 + 5 + Then you write `try/catch` and it all falls apart. 6 + 7 + ```typescript 8 + try { 9 + const user = await fetchUser(id) 10 + const orders = await fetchOrders(user.id) 11 + return processOrders(orders) 12 + } catch (e) { 13 + // What is e? Network error? Not found? Parse error? Server error? 14 + // TypeScript shrugs: "It's unknown. Good luck." 15 + } 16 + ``` 17 + 18 + This chapter explains why exceptions break TypeScript's type safety and how purus fixes it. 19 + 20 + --- 21 + 22 + ## The Problem: Exceptions Are Invisible 23 + 24 + Consider a simple function: 25 + 26 + ```typescript 27 + const divide = (a: number, b: number): number => { 28 + if (b === 0) throw new Error("Division by zero") 29 + return a / b 30 + } 31 + ``` 32 + 33 + The return type says `number`. But that's a lie - this function can also throw. The type signature doesn't mention it. TypeScript doesn't track it. Your IDE won't warn you. 34 + 35 + Now imagine this in a real codebase: 36 + 37 + ```typescript 38 + const processOrder = async (orderId: string): Promise<Order> => { 39 + const order = await db.findOrder(orderId) // Throws if not found 40 + const user = await db.findUser(order.userId) // Throws if not found 41 + await payment.charge(user, order.total) // Throws if declined 42 + await inventory.reserve(order.items) // Throws if out of stock 43 + await email.send(user.email, "Order confirmed") // Throws if invalid email 44 + return order 45 + } 46 + ``` 47 + 48 + How many things can fail? Five. What are the errors? Unknown. The type signature says `Promise<Order>` but the reality is: 49 + 50 + ``` 51 + Promise<Order | NetworkError | NotFoundError | PaymentError | InventoryError | EmailError> 52 + ``` 53 + 54 + TypeScript can't express this. So errors become invisible. They bubble up through your call stack, get caught somewhere (maybe), and by then you've lost all context about what actually failed. 55 + 56 + --- 57 + 58 + ## The Solution: Errors as Values 59 + 60 + What if errors were part of the return type? 61 + 62 + ```typescript 63 + const divide = (a: number, b: number): Result<number, DivisionError> => { 64 + if (b === 0) return err({ _tag: "DivisionByZero" }) 65 + return ok(a / b) 66 + } 67 + ``` 68 + 69 + Now the type tells the truth. This function returns either: 70 + - `Ok<number>` - the division succeeded 71 + - `Err<DivisionError>` - division by zero 72 + 73 + The caller must handle both cases: 74 + 75 + ```typescript 76 + const result = divide(10, 0) 77 + 78 + if (result._tag === "Ok") { 79 + console.log(`Result: ${result.value}`) 80 + } else { 81 + console.log(`Error: ${result.error._tag}`) 82 + } 83 + ``` 84 + 85 + No try/catch. No invisible failure modes. The type system tracks everything. 86 + 87 + --- 88 + 89 + ## Composition: The pipe() Pattern 90 + 91 + Handling Result manually gets verbose. Consider: 92 + 93 + ```typescript 94 + const result1 = divide(10, 2) 95 + if (result1._tag === "Err") return result1 96 + 97 + const result2 = divide(result1.value, 3) 98 + if (result2._tag === "Err") return result2 99 + 100 + return ok(result2.value * 2) 101 + ``` 102 + 103 + purus provides `pipe()` and combinators that handle this: 104 + 105 + ```typescript 106 + import { pipe, ok, mapResult, chainResult } from "purus-ts" 107 + 108 + const result = pipe( 109 + divide(10, 2), 110 + chainResult(n => divide(n, 3)), 111 + mapResult(n => n * 2) 112 + ) 113 + ``` 114 + 115 + If any step fails, the error propagates automatically. If all succeed, you get the final value. The code reads top-to-bottom, no nesting, no early returns. 116 + 117 + This is the core insight: **errors flow through the pipeline just like success values**. You compose operations, and error handling comes for free. 118 + 119 + --- 120 + 121 + ## What purus Provides 122 + 123 + purus is a minimal library (~950 lines) that gives you: 124 + 125 + ### Errors as Values 126 + - **Result<T, E>** - Success or failure with typed error 127 + - **Option<T>** - Value or nothing (like nullable, but composable) 128 + 129 + ### Pattern Matching 130 + - **match()** - Exhaustive matching (forget a case = compile error) 131 + - **matchResult()**, **matchOption()** - Specialized matchers 132 + 133 + ### Type-Safe Primitives 134 + - **Branded<T, B>** - Distinct types from primitives (UserId vs OrderId) 135 + - **Refined<T, R>** - Types with validation (PositiveNumber, Email) 136 + - **Entity<T, S>** - Typestate for state machines 137 + 138 + ### Effect System 139 + - **Eff<A, E, R>** - Lazy, composable, typed async operations 140 + - **Combinators** - timeout, retry, race, all 141 + - **Dependency Injection** - provide(), access() without frameworks 142 + 143 + ### Concurrency 144 + - **Fibers** - Lightweight threads with cancellation 145 + - **Real cleanup** - Unlike Promise.race, cancelled work actually stops 146 + 147 + --- 148 + 149 + ## Your First Example 150 + 151 + Let's see the difference in practice. Here's typical TypeScript: 152 + 153 + ```typescript 154 + // Vanilla TypeScript 155 + const fetchUser = async (id: string): Promise<User> => { 156 + const response = await fetch(`/users/${id}`) 157 + if (!response.ok) { 158 + throw new Error(`HTTP ${response.status}`) 159 + } 160 + return response.json() 161 + } 162 + 163 + // Caller has no idea what can fail 164 + try { 165 + const user = await fetchUser("123") 166 + console.log(user.name) 167 + } catch (e) { 168 + // e is unknown. Network error? 404? 500? JSON parse error? 169 + console.error("Something went wrong:", e) 170 + } 171 + ``` 172 + 173 + Here's the same thing with purus: 174 + 175 + ```typescript 176 + // With purus 177 + import { type Result, ok, err, matchResult } from "purus-ts" 178 + 179 + type FetchError = 180 + | { _tag: "NetworkError"; message: string } 181 + | { _tag: "NotFound"; id: string } 182 + | { _tag: "ServerError"; status: number } 183 + 184 + const fetchUser = async (id: string): Promise<Result<User, FetchError>> => { 185 + try { 186 + const response = await fetch(`/users/${id}`) 187 + if (response.status === 404) { 188 + return err({ _tag: "NotFound", id }) 189 + } 190 + if (!response.ok) { 191 + return err({ _tag: "ServerError", status: response.status }) 192 + } 193 + return ok(await response.json()) 194 + } catch (e) { 195 + return err({ _tag: "NetworkError", message: String(e) }) 196 + } 197 + } 198 + 199 + // Caller knows exactly what can fail 200 + const result = await fetchUser("123") 201 + 202 + matchResult( 203 + user => console.log(user.name), 204 + error => { 205 + // TypeScript knows error is FetchError 206 + // match() forces us to handle all cases 207 + switch (error._tag) { 208 + case "NetworkError": console.error("Network:", error.message); break 209 + case "NotFound": console.error("User not found:", error.id); break 210 + case "ServerError": console.error("Server error:", error.status); break 211 + } 212 + } 213 + )(result) 214 + ``` 215 + 216 + The purus version is more code upfront, but: 217 + 218 + 1. **The type tells the truth** - `Result<User, FetchError>` is honest 219 + 2. **Errors are structured** - Each has a `_tag` and relevant data 220 + 3. **Handling is exhaustive** - Add a new error type, compiler reminds you 221 + 4. **No surprises** - You can't forget to handle failure 222 + 223 + --- 224 + 225 + ## When to Use This Approach 226 + 227 + Errors as values shine when: 228 + 229 + - **Multiple things can fail** - You need to know which one 230 + - **Errors need context** - NotFound needs the ID, Validation needs the field 231 + - **Recovery is possible** - You want to try fallbacks or retry 232 + - **Testing matters** - No mocking throw behavior, just return values 233 + 234 + Traditional exceptions are fine for: 235 + 236 + - **Truly exceptional cases** - Out of memory, stack overflow 237 + - **Programmer errors** - Assertions, invariant violations 238 + - **Quick scripts** - When you don't care about error handling 239 + 240 + --- 241 + 242 + ## What's Next 243 + 244 + In the next chapter, you'll learn about the Effect type - purus's core abstraction for async operations. Effects take the ideas from this chapter and make them work with async code, cancellation, and dependency injection. 245 + 246 + [Continue to Chapter 2: Your First Effect →](./02-your-first-effect.md)