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 2: Your First Effect

+414
+414
docs/guides/tutorial/02-your-first-effect.md
··· 1 + # Chapter 2: Your First Effect 2 + 3 + In the previous chapter, we saw how `Result<T, E>` makes errors visible in the type system. But Result is synchronous - it holds a value that already exists. 4 + 5 + What about async operations? Fetching data, reading files, calling APIs - these are where most errors happen, and where we need the most help. 6 + 7 + This chapter introduces `Eff<A, E, R>` - purus's Effect type for composable async operations. 8 + 9 + --- 10 + 11 + ## What is an Effect? 12 + 13 + An Effect is a **description of work to be done**, not the work itself. 14 + 15 + ```typescript 16 + import { succeed, runPromise } from "purus-ts" 17 + 18 + // This doesn't print anything yet 19 + const greeting = succeed("Hello, World!") 20 + 21 + // Now it runs 22 + const result = await runPromise(greeting) 23 + console.log(result) // "Hello, World!" 24 + ``` 25 + 26 + This might seem like extra steps for no benefit. But laziness is the key to composition. 27 + 28 + ### Why Laziness Matters 29 + 30 + Consider this Promise-based code: 31 + 32 + ```typescript 33 + const fetchUser = fetch("/user/1") // Request starts IMMEDIATELY 34 + const fetchOrders = fetch("/orders") // This too, even if we don't need it 35 + 36 + // What if we wanted to run them sequentially? 37 + // Too late - they're already running in parallel 38 + ``` 39 + 40 + With Effects: 41 + 42 + ```typescript 43 + const fetchUser = fromPromise(() => fetch("/user/1")) // Nothing happens 44 + const fetchOrders = fromPromise(() => fetch("/orders")) // Still nothing 45 + 46 + // Now we choose: sequential or parallel 47 + const sequential = pipe(fetchUser, flatMap(() => fetchOrders)) 48 + const parallel = all([fetchUser, fetchOrders]) 49 + ``` 50 + 51 + Effects let you describe what should happen, then decide how and when to run it. 52 + 53 + --- 54 + 55 + ## The Eff Type 56 + 57 + An Effect has three type parameters: 58 + 59 + ```typescript 60 + Eff<A, E, R> 61 + // ^ ^ ^ 62 + // | | └── R: Requirements (dependencies the effect needs) 63 + // | └───── E: Error type (what can go wrong) 64 + // └──────── A: Success type (what you get if it works) 65 + ``` 66 + 67 + For now, we'll focus on A and E. We'll cover R (requirements) in Chapter 9. 68 + 69 + --- 70 + 71 + ## Creating Effects 72 + 73 + ### succeed() - A value that's already computed 74 + 75 + ```typescript 76 + import { succeed } from "purus-ts" 77 + 78 + const five = succeed(5) 79 + // Type: Eff<number, never, unknown> 80 + // never error type means it can't fail 81 + ``` 82 + 83 + ### fail() - An error 84 + 85 + ```typescript 86 + import { fail } from "purus-ts" 87 + 88 + type MyError = { _tag: "MyError"; message: string } 89 + 90 + const oops = fail<MyError>({ _tag: "MyError", message: "Something went wrong" }) 91 + // Type: Eff<never, MyError, unknown> 92 + // never success type means it always fails 93 + ``` 94 + 95 + ### sync() - Synchronous computation 96 + 97 + ```typescript 98 + import { sync } from "purus-ts" 99 + 100 + const now = sync(() => new Date()) 101 + // Type: Eff<Date, never, unknown> 102 + // The function runs each time the effect is executed 103 + ``` 104 + 105 + Use `sync()` when you need to delay a synchronous computation: 106 + 107 + ```typescript 108 + // Without sync - computes immediately 109 + const immediate = succeed(Math.random()) // Same value every time 110 + 111 + // With sync - computes when run 112 + const delayed = sync(() => Math.random()) // New value each time 113 + ``` 114 + 115 + ### attempt() - Catch thrown exceptions 116 + 117 + ```typescript 118 + import { attempt } from "purus-ts" 119 + 120 + const parsed = attempt(() => JSON.parse(userInput)) 121 + // Type: Eff<unknown, unknown, unknown> 122 + // If JSON.parse throws, it becomes an error value 123 + ``` 124 + 125 + The error type is `unknown` because we don't know what might be thrown. You can refine this: 126 + 127 + ```typescript 128 + import { pipe, catchAll, fail } from "purus-ts" 129 + 130 + type ParseError = { _tag: "ParseError"; input: string } 131 + 132 + const safeParse = (input: string) => pipe( 133 + attempt(() => JSON.parse(input)), 134 + catchAll(() => fail<ParseError>({ _tag: "ParseError", input })) 135 + ) 136 + // Type: Eff<unknown, ParseError, unknown> 137 + ``` 138 + 139 + ### fromPromise() - Bridge from Promises 140 + 141 + ```typescript 142 + import { fromPromise } from "purus-ts" 143 + 144 + const fetchData = fromPromise(() => fetch("/api/data").then(r => r.json())) 145 + // Type: Eff<unknown, unknown, unknown> 146 + ``` 147 + 148 + The function returns a Promise. It's wrapped in a thunk `() =>` so it doesn't start immediately. 149 + 150 + ### async() - Full control over async behavior 151 + 152 + For advanced cases where you need cleanup or cancellation: 153 + 154 + ```typescript 155 + import { async, Exit } from "purus-ts" 156 + 157 + const delay = (ms: number) => async<void, never>(resume => { 158 + const id = setTimeout(() => resume(Exit.succeed(undefined)), ms) 159 + return () => clearTimeout(id) // Cleanup function 160 + }) 161 + ``` 162 + 163 + We'll explore `async()` more in Chapter 8 on concurrency. 164 + 165 + --- 166 + 167 + ## Running Effects 168 + 169 + Effects are just descriptions until you run them. 170 + 171 + ### runPromise() - Run and get the value 172 + 173 + ```typescript 174 + import { succeed, runPromise } from "purus-ts" 175 + 176 + const effect = succeed(42) 177 + const value = await runPromise(effect) 178 + console.log(value) // 42 179 + ``` 180 + 181 + If the effect fails, `runPromise` throws: 182 + 183 + ```typescript 184 + import { fail, runPromise } from "purus-ts" 185 + 186 + const effect = fail({ _tag: "Oops" }) 187 + await runPromise(effect) // Throws! 188 + ``` 189 + 190 + ### runPromiseExit() - Run and get success or failure 191 + 192 + For handling both cases: 193 + 194 + ```typescript 195 + import { succeed, fail, runPromiseExit } from "purus-ts" 196 + 197 + const exit1 = await runPromiseExit(succeed(42)) 198 + // { _tag: "Success", value: 42 } 199 + 200 + const exit2 = await runPromiseExit(fail({ _tag: "Oops" })) 201 + // { _tag: "Failure", error: { _tag: "Oops" } } 202 + ``` 203 + 204 + This is useful for testing and when you need to handle errors at the top level. 205 + 206 + --- 207 + 208 + ## Transforming Effects 209 + 210 + ### mapEff() - Transform the success value 211 + 212 + ```typescript 213 + import { succeed, mapEff, pipe, runPromise } from "purus-ts" 214 + 215 + const doubled = pipe( 216 + succeed(21), 217 + mapEff(n => n * 2) 218 + ) 219 + 220 + await runPromise(doubled) // 42 221 + ``` 222 + 223 + If the effect fails, `mapEff` does nothing - the error passes through. 224 + 225 + ### flatMap() - Chain effects together 226 + 227 + This is the core composition operator. Use it when one effect depends on another: 228 + 229 + ```typescript 230 + import { succeed, flatMap, pipe, runPromise } from "purus-ts" 231 + 232 + const program = pipe( 233 + succeed(10), 234 + flatMap(a => succeed(a + 5)), 235 + flatMap(b => succeed(b * 2)) 236 + ) 237 + 238 + await runPromise(program) // 30 239 + ``` 240 + 241 + `flatMap` is like `then()` for Promises, but: 242 + - It's lazy (nothing runs until `runPromise`) 243 + - It preserves error types 244 + - It composes with other combinators 245 + 246 + ### catchAll() - Handle errors 247 + 248 + ```typescript 249 + import { fail, catchAll, succeed, pipe, runPromise } from "purus-ts" 250 + 251 + const recovered = pipe( 252 + fail({ _tag: "NetworkError", message: "timeout" }), 253 + catchAll(error => { 254 + console.log(`Caught: ${error.message}`) 255 + return succeed("fallback value") 256 + }) 257 + ) 258 + 259 + await runPromise(recovered) // "fallback value" 260 + ``` 261 + 262 + ### foldEff() - Handle both success and failure 263 + 264 + ```typescript 265 + import { succeed, foldEff, pipe, runPromise } from "purus-ts" 266 + 267 + const result = pipe( 268 + succeed(42), 269 + foldEff( 270 + error => succeed(`Error: ${error}`), 271 + value => succeed(`Success: ${value}`) 272 + ) 273 + ) 274 + 275 + await runPromise(result) // "Success: 42" 276 + ``` 277 + 278 + --- 279 + 280 + ## The pipe() Pattern 281 + 282 + `pipe()` is how you compose operations in purus: 283 + 284 + ```typescript 285 + import { pipe, succeed, mapEff, flatMap } from "purus-ts" 286 + 287 + const result = pipe( 288 + succeed(10), // Start with 10 289 + mapEff(n => n * 2), // Transform to 20 290 + flatMap(n => succeed(n + 5)), // Chain to 25 291 + mapEff(String) // Convert to "25" 292 + ) 293 + ``` 294 + 295 + Read it top-to-bottom: start with a value, apply each transformation in order. 296 + 297 + Without `pipe()`, you'd write: 298 + 299 + ```typescript 300 + mapEff(String)(flatMap(n => succeed(n + 5))(mapEff(n => n * 2)(succeed(10)))) 301 + ``` 302 + 303 + Which is inside-out and hard to read. 304 + 305 + --- 306 + 307 + ## Exercise: Build a Validator 308 + 309 + Let's practice by building a simple input validator. 310 + 311 + ### The Goal 312 + 313 + Create a function that validates user input: 314 + - Name must be non-empty 315 + - Age must be a positive number 316 + - Email must contain @ 317 + 318 + ### Step 1: Define the types 319 + 320 + ```typescript 321 + type ValidationError = 322 + | { _tag: "EmptyName" } 323 + | { _tag: "InvalidAge"; value: number } 324 + | { _tag: "InvalidEmail"; value: string } 325 + 326 + type UserInput = { 327 + name: string 328 + age: number 329 + email: string 330 + } 331 + 332 + type ValidUser = { 333 + name: string 334 + age: number 335 + email: string 336 + } 337 + ``` 338 + 339 + ### Step 2: Create validators 340 + 341 + ```typescript 342 + import { type Eff, succeed, fail } from "purus-ts" 343 + 344 + const validateName = (name: string): Eff<string, ValidationError, unknown> => 345 + name.trim().length > 0 346 + ? succeed(name.trim()) 347 + : fail({ _tag: "EmptyName" }) 348 + 349 + const validateAge = (age: number): Eff<number, ValidationError, unknown> => 350 + age > 0 351 + ? succeed(age) 352 + : fail({ _tag: "InvalidAge", value: age }) 353 + 354 + const validateEmail = (email: string): Eff<string, ValidationError, unknown> => 355 + email.includes("@") 356 + ? succeed(email) 357 + : fail({ _tag: "InvalidEmail", value: email }) 358 + ``` 359 + 360 + ### Step 3: Compose them 361 + 362 + ```typescript 363 + import { pipe, flatMap, mapEff } from "purus-ts" 364 + 365 + const validateUser = (input: UserInput): Eff<ValidUser, ValidationError, unknown> => 366 + pipe( 367 + validateName(input.name), 368 + flatMap(name => 369 + pipe( 370 + validateAge(input.age), 371 + flatMap(age => 372 + pipe( 373 + validateEmail(input.email), 374 + mapEff(email => ({ name, age, email })) 375 + ) 376 + ) 377 + ) 378 + ) 379 + ) 380 + ``` 381 + 382 + ### Step 4: Use it 383 + 384 + ```typescript 385 + import { runPromiseExit } from "purus-ts" 386 + 387 + const goodInput = { name: "Alice", age: 30, email: "alice@example.com" } 388 + const badInput = { name: "", age: -5, email: "no-at-sign" } 389 + 390 + const result1 = await runPromiseExit(validateUser(goodInput)) 391 + // { _tag: "Success", value: { name: "Alice", age: 30, email: "alice@example.com" } } 392 + 393 + const result2 = await runPromiseExit(validateUser(badInput)) 394 + // { _tag: "Failure", error: { _tag: "EmptyName" } } 395 + // Note: Fails on first error (short-circuit behavior) 396 + ``` 397 + 398 + --- 399 + 400 + ## Key Takeaways 401 + 402 + 1. **Effects are descriptions** - They don't run until you call `runPromise` 403 + 2. **Laziness enables composition** - You decide when and how to run 404 + 3. **Eff<A, E, R>** - Success type, Error type, Requirements 405 + 4. **flatMap chains effects** - Like Promise.then, but lazy and typed 406 + 5. **pipe() reads top-to-bottom** - Cleaner than nested function calls 407 + 408 + --- 409 + 410 + ## What's Next 411 + 412 + In the next chapter, we'll dive deeper into `Result<T, E>` for synchronous error handling - the building block that makes typed errors possible. 413 + 414 + [Continue to Chapter 3: Typed Errors with Result →](./03-typed-errors-with-result.md)