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 documentation for typestate, effects, fibers, DI, and testing

+1854 -14
+7 -7
docs/guides/README.md
··· 47 47 48 48 | Article | Description | 49 49 |---------------------------------------------------------|--------------------------------------------------------| 50 - | [Why Errors as Values?](./concepts/errors-as-values.md) | The exception problem, railway oriented programming | 51 - | [Branded Types In Depth](./concepts/branded-types.md) | Phantom types, smart constructors, production patterns | 52 - | Typestate Pattern | State machines in the type system | 53 - | Effect Composition | Building complex effects from simple ones | 54 - | Fiber Internals | How the runtime works under the hood | 55 - | Dependency Injection Patterns | Testing, layered environments | 56 - | Testing Strategies | Unit testing effectful code | 50 + | [Why Errors as Values?](./concepts/01-errors-as-values.md) | The exception problem, railway oriented programming | 51 + | [Branded Types In Depth](./concepts/02-branded-types.md) | Phantom types, smart constructors, production patterns | 52 + | [Typestate Pattern](./concepts/05-typestate-pattern.md) | State machines in the type system | 53 + | [Effect Composition](./concepts/06-effect-composition.md) | Building complex effects from simple ones | 54 + | [Fiber Internals](./concepts/07-fiber-internals.md) | How the runtime works under the hood | 55 + | [Dependency Injection Patterns](./concepts/08-dependency-injection-patterns.md) | Testing, layered environments | 56 + | [Testing Strategies](./concepts/09-testing-strategies.md) | Unit testing effectful code | 57 57 58 58 --- 59 59
+1 -1
docs/guides/concepts/02-branded-types.md
··· 435 435 436 436 - [Tutorial Chapter 6: Branded Types](../tutorial/06-branded-types.md) - Hands-on introduction 437 437 - [Workflow Engine Example](../../../examples/workflow-engine/) - Branded types in action 438 - - [Typestate Pattern](./typestate-pattern.md) - Encoding state machines with phantom types 438 + - [Typestate Pattern](./05-typestate-pattern.md) - Encoding state machines with phantom types
+1 -1
docs/guides/concepts/04-type-classes-in-typescript.md
··· 684 684 685 685 - [Validation and Error Accumulation](./03-validation-and-error-accumulation.md) - Applicative in action 686 686 - [Why Errors as Values?](./01-errors-as-values.md) - Result fundamentals 687 - - [Effect Composition](./effect-composition.md) - How effects compose internally 687 + - [Effect Composition](./06-effect-composition.md) - How effects compose internally 688 688 - [Tutorial Chapter 3: Typed Errors with Result](../tutorial/03-typed-errors-with-result.md) - Hands-on Result practice
+323
docs/guides/concepts/05-typestate-pattern.md
··· 1 + # Typestate Pattern 2 + 3 + This article explains how to encode state machines in the type system, making invalid state transitions impossible at compile time. 4 + 5 + --- 6 + 7 + ## The Problem: Runtime State Checks 8 + 9 + Consider a document workflow where documents move from Draft to Review to Published: 10 + 11 + ```typescript 12 + interface Document { 13 + id: string 14 + content: string 15 + status: "draft" | "review" | "published" 16 + } 17 + 18 + const submitForReview = (doc: Document): Document => { 19 + if (doc.status !== "draft") { 20 + throw new Error("Can only submit drafts for review") 21 + } 22 + return { ...doc, status: "review" } 23 + } 24 + 25 + const publish = (doc: Document): Document => { 26 + if (doc.status !== "review") { 27 + throw new Error("Can only publish reviewed documents") 28 + } 29 + return { ...doc, status: "published" } 30 + } 31 + ``` 32 + 33 + This works, but has problems: 34 + 35 + 1. **Errors are runtime** - Invalid transitions crash the program 36 + 2. **Checks are manual** - Each function must validate state 37 + 3. **Errors are invisible** - TypeScript can't warn you about `publish(draftDoc)` 38 + 4. **Tests must cover every path** - You need tests for each invalid transition 39 + 40 + What if the type system could enforce valid transitions? 41 + 42 + --- 43 + 44 + ## Typestate: States in the Type System 45 + 46 + Typestate encodes the current state as a type parameter. Different states are different types, and only valid transitions compile. 47 + 48 + ```typescript 49 + // Draft documents and Published documents are different types 50 + type DraftDoc = Document & { __state: "draft" } 51 + type PublishedDoc = Document & { __state: "published" } 52 + 53 + // This function ONLY accepts drafts 54 + const submitForReview = (doc: DraftDoc): ReviewDoc => ... 55 + 56 + // TypeScript error: PublishedDoc is not DraftDoc 57 + submitForReview(publishedDoc) 58 + ``` 59 + 60 + The state lives in the type, not just the data. Invalid transitions fail at compile time. 61 + 62 + --- 63 + 64 + ## The Entity<T, S> Type 65 + 66 + purus provides `Entity<T, S>` for typestate: 67 + 68 + ```typescript 69 + import { type Entity, entity, transition } from "purus-ts" 70 + 71 + // Entity<T, S> = T plus a phantom state tag 72 + type Entity<T, S extends string> = T & State<S> 73 + ``` 74 + 75 + The `State<S>` is a phantom type - it exists only in the type system and has zero runtime cost. An `Entity<Document, "draft">` is just a `Document` at runtime. 76 + 77 + ### Creating Entities 78 + 79 + Use `entity()` to create an entity in an initial state: 80 + 81 + ```typescript 82 + interface Order { 83 + id: string 84 + items: string[] 85 + total: number 86 + } 87 + 88 + // Create an order in "pending" state 89 + const newOrder = entity<Order, "pending">({ 90 + id: "order-123", 91 + items: ["item-1"], 92 + total: 100 93 + }) 94 + // Type: Entity<Order, "pending"> 95 + ``` 96 + 97 + --- 98 + 99 + ## Defining Transitions 100 + 101 + The `transition()` function creates type-safe state transitions: 102 + 103 + ```typescript 104 + import { transition } from "purus-ts" 105 + 106 + // transition<T, From, To> creates a function 107 + // that converts Entity<T, From> to Entity<T, To> 108 + 109 + const confirm = transition<Order, "pending", "confirmed">() 110 + const ship = transition<Order, "confirmed", "shipped">() 111 + const deliver = transition<Order, "shipped", "delivered">() 112 + ``` 113 + 114 + Now TypeScript enforces the workflow: 115 + 116 + ```typescript 117 + const pending = entity<Order, "pending">({ ... }) 118 + 119 + const confirmed = confirm(pending) // Entity<Order, "confirmed"> 120 + const shipped = ship(confirmed) // Entity<Order, "shipped"> 121 + const delivered = deliver(shipped) // Entity<Order, "delivered"> 122 + 123 + // Compile errors: 124 + ship(pending) // Error: "pending" is not "confirmed" 125 + deliver(pending) // Error: "pending" is not "shipped" 126 + confirm(shipped) // Error: "shipped" is not "pending" 127 + ``` 128 + 129 + ### Transitions with Transformations 130 + 131 + Often state transitions also modify the data. Pass a transform function: 132 + 133 + ```typescript 134 + interface Order { 135 + id: string 136 + items: string[] 137 + shippedAt?: Date 138 + deliveredAt?: Date 139 + } 140 + 141 + const ship = transition<Order, "confirmed", "shipped">(order => ({ 142 + ...order, 143 + shippedAt: new Date() 144 + })) 145 + 146 + const deliver = transition<Order, "shipped", "delivered">(order => ({ 147 + ...order, 148 + deliveredAt: new Date() 149 + })) 150 + ``` 151 + 152 + --- 153 + 154 + ## Real Example: Order Workflow 155 + 156 + Here's a complete e-commerce order workflow: 157 + 158 + ```typescript 159 + import { type Entity, entity, transition } from "purus-ts" 160 + 161 + // State types for documentation 162 + type Pending = "pending" 163 + type Confirmed = "confirmed" 164 + type Shipped = "shipped" 165 + type Delivered = "delivered" 166 + type Cancelled = "cancelled" 167 + 168 + interface Order { 169 + id: string 170 + customerId: string 171 + items: Array<{ productId: string; quantity: number }> 172 + confirmedAt?: Date 173 + shippedAt?: Date 174 + deliveredAt?: Date 175 + cancelledAt?: Date 176 + } 177 + 178 + // Transitions 179 + const confirmOrder = transition<Order, Pending, Confirmed>(order => ({ 180 + ...order, 181 + confirmedAt: new Date() 182 + })) 183 + 184 + const shipOrder = transition<Order, Confirmed, Shipped>(order => ({ 185 + ...order, 186 + shippedAt: new Date() 187 + })) 188 + 189 + const deliverOrder = transition<Order, Shipped, Delivered>(order => ({ 190 + ...order, 191 + deliveredAt: new Date() 192 + })) 193 + 194 + // Cancellation can happen from pending or confirmed 195 + const cancelPending = transition<Order, Pending, Cancelled>(order => ({ 196 + ...order, 197 + cancelledAt: new Date() 198 + })) 199 + 200 + const cancelConfirmed = transition<Order, Confirmed, Cancelled>(order => ({ 201 + ...order, 202 + cancelledAt: new Date() 203 + })) 204 + 205 + // Usage 206 + const newOrder = entity<Order, Pending>({ 207 + id: "order-123", 208 + customerId: "cust-456", 209 + items: [{ productId: "prod-789", quantity: 2 }] 210 + }) 211 + 212 + // Happy path 213 + const confirmed = confirmOrder(newOrder) 214 + const shipped = shipOrder(confirmed) 215 + const delivered = deliverOrder(shipped) 216 + 217 + // Or cancel early 218 + const cancelled = cancelPending(newOrder) 219 + // const cancelTooLate = cancelPending(shipped) // Error! 220 + ``` 221 + 222 + --- 223 + 224 + ## Real Example: Document Workflow 225 + 226 + A publishing workflow with approval steps: 227 + 228 + ```typescript 229 + interface Document { 230 + title: string 231 + content: string 232 + author: string 233 + reviewNotes?: string 234 + publishedAt?: Date 235 + } 236 + 237 + type Draft = "draft" 238 + type Review = "review" 239 + type Approved = "approved" 240 + type Published = "published" 241 + type Rejected = "rejected" 242 + 243 + // Transitions 244 + const submitForReview = transition<Document, Draft, Review>() 245 + 246 + const approve = transition<Document, Review, Approved>(doc => ({ 247 + ...doc, 248 + reviewNotes: "Approved for publication" 249 + })) 250 + 251 + const reject = transition<Document, Review, Rejected>(doc => ({ 252 + ...doc, 253 + reviewNotes: "Needs revision" 254 + })) 255 + 256 + const publish = transition<Document, Approved, Published>(doc => ({ 257 + ...doc, 258 + publishedAt: new Date() 259 + })) 260 + 261 + // Rejected docs can be revised back to draft 262 + const revise = transition<Document, Rejected, Draft>(doc => ({ 263 + ...doc, 264 + reviewNotes: undefined 265 + })) 266 + 267 + // Type-safe workflow 268 + const draft = entity<Document, Draft>({ 269 + title: "My Article", 270 + content: "...", 271 + author: "Alice" 272 + }) 273 + 274 + const inReview = submitForReview(draft) 275 + const approved = approve(inReview) 276 + const published = publish(approved) 277 + 278 + // publish(draft) would not compile! 279 + ``` 280 + 281 + --- 282 + 283 + ## When to Use Typestate 284 + 285 + ### Good Candidates 286 + 287 + - **Order processing** - pending, paid, shipped, delivered 288 + - **Document workflows** - draft, review, approved, published 289 + - **Connection states** - disconnected, connecting, connected 290 + - **Authentication** - anonymous, authenticated, admin 291 + - **Resource lifecycle** - created, initialized, active, disposed 292 + 293 + ### Signs You Need Typestate 294 + 295 + - Runtime "invalid state" errors 296 + - Functions that check status before proceeding 297 + - State machines with strict transition rules 298 + - Bugs from calling methods in wrong order 299 + 300 + ### When to Skip 301 + 302 + - Simple boolean flags (use branded types instead) 303 + - States that can transition freely 304 + - Prototyping (add rigor later) 305 + 306 + --- 307 + 308 + ## Key Takeaways 309 + 310 + 1. **Typestate encodes state in types** - Different states are different types 311 + 2. **Invalid transitions don't compile** - No runtime checks needed 312 + 3. **Zero runtime cost** - Phantom types exist only in the type system 313 + 4. **Use `entity()` for creation** - Creates an entity in initial state 314 + 5. **Use `transition()` for state changes** - Type-safe, with optional transform 315 + 316 + Typestate turns runtime errors into compile-time errors. The type system enforces your state machine, so invalid workflows are impossible to write. 317 + 318 + --- 319 + 320 + ## See Also 321 + 322 + - [Branded Types In Depth](./02-branded-types.md) - Foundation for phantom types 323 + - [Workflow Engine Example](../../../examples/workflow-engine/) - Full order processing system
+345
docs/guides/concepts/06-effect-composition.md
··· 1 + # Effect Composition 2 + 3 + This article explains how purus effects compose together, building complex programs from simple building blocks. 4 + 5 + --- 6 + 7 + ## Effects as Data 8 + 9 + The key insight in purus is that effects are data, not computation. 10 + 11 + When you write: 12 + 13 + ```typescript 14 + const program = pipe( 15 + succeed(10), 16 + flatMap(n => succeed(n * 2)), 17 + mapEff(n => n.toString()) 18 + ) 19 + ``` 20 + 21 + Nothing runs. You've built a data structure: 22 + 23 + ``` 24 + FlatMap { 25 + effect: FlatMap { 26 + effect: Succeed { value: 10 } 27 + f: n => Succeed { value: n * 2 } 28 + } 29 + f: n => Succeed { value: n.toString() } 30 + } 31 + ``` 32 + 33 + The `Eff<A, E, R>` type is a discriminated union describing WHAT to do: 34 + 35 + ```typescript 36 + type Eff<A, E, R> = 37 + | { _tag: "Succeed"; value: A } // Return a value 38 + | { _tag: "Fail"; error: E } // Fail with error 39 + | { _tag: "Sync"; f: () => A } // Run sync function 40 + | { _tag: "Async"; register: ... } // Async operation 41 + | { _tag: "FlatMap"; effect: ...; f: ...} // Sequence two effects 42 + | { _tag: "Fold"; ... } // Handle success/failure 43 + | { _tag: "Access"; f: (r: R) => A } // Read environment 44 + | { _tag: "Provide"; effect: ...; env } // Supply environment 45 + | { _tag: "Fork"; effect: ... } // Run in background 46 + ``` 47 + 48 + This is the "free monad" pattern simplified: you build a tree of instructions, then an interpreter executes them. 49 + 50 + ### Why Effects as Data? 51 + 52 + 1. **Lazy** - Nothing runs until you call `runPromise` 53 + 2. **Composable** - Effects combine without executing 54 + 3. **Testable** - You can inspect the tree structure 55 + 4. **Refactorable** - The same effect can be run, logged, mocked, or transformed 56 + 57 + --- 58 + 59 + ## The FlatMap Pattern 60 + 61 + `flatMap` is the fundamental composition operator. It sequences two effects where the second depends on the first. 62 + 63 + ```typescript 64 + import { succeed, flatMap, pipe } from "purus-ts" 65 + 66 + // flatMap: (a: A) => Eff<B, E, R> => Eff<A, E, R> => Eff<B, E, R> 67 + 68 + const program = pipe( 69 + succeed(10), 70 + flatMap(n => succeed(n * 2)), 71 + flatMap(n => succeed(n + 5)) 72 + ) 73 + // Type: Eff<number, never, unknown> 74 + // Result when run: 25 75 + ``` 76 + 77 + ### flatMap vs map 78 + 79 + - `mapEff` transforms the success value with a pure function 80 + - `flatMap` transforms with a function that returns an effect 81 + 82 + ```typescript 83 + // mapEff: transform value (A => B) 84 + pipe(succeed(10), mapEff(n => n * 2)) 85 + 86 + // flatMap: transform to new effect (A => Eff<B, E, R>) 87 + pipe(succeed(10), flatMap(n => fetchUser(n))) 88 + ``` 89 + 90 + Use `flatMap` when the next step requires an effect (async, may fail, needs environment). 91 + 92 + ### Short-Circuit on Error 93 + 94 + If any effect in the chain fails, subsequent `flatMap` calls are skipped: 95 + 96 + ```typescript 97 + const program = pipe( 98 + succeed(10), 99 + flatMap(n => fail({ _tag: "Error" })), // Fails here 100 + flatMap(n => succeed(n * 2)), // Skipped 101 + flatMap(n => succeed(n.toString())) // Skipped 102 + ) 103 + // Type: Eff<string, { _tag: "Error" }, unknown> 104 + // Result: Failure({ _tag: "Error" }) 105 + ``` 106 + 107 + This is "railway oriented programming" - the error channel runs parallel to the success channel. 108 + 109 + --- 110 + 111 + ## Sequencing with pipe() 112 + 113 + `pipe()` enables readable left-to-right composition: 114 + 115 + ```typescript 116 + // Without pipe (nested, inside-out): 117 + mapEff(toString)(flatMap(n => succeed(n + 5))(mapEff(n => n * 2)(succeed(10)))) 118 + 119 + // With pipe (sequential, top-to-bottom): 120 + pipe( 121 + succeed(10), 122 + mapEff(n => n * 2), 123 + flatMap(n => succeed(n + 5)), 124 + mapEff(toString) 125 + ) 126 + ``` 127 + 128 + `pipe()` works with any functions, not just effects: 129 + 130 + ```typescript 131 + const result = pipe( 132 + " hello world ", 133 + s => s.trim(), 134 + s => s.toUpperCase(), 135 + s => s.split(" ") 136 + ) 137 + // ["HELLO", "WORLD"] 138 + ``` 139 + 140 + ### flow() for Reusable Pipelines 141 + 142 + `flow()` creates a function instead of applying immediately: 143 + 144 + ```typescript 145 + import { flow, mapEff, flatMap } from "purus-ts" 146 + 147 + // Create reusable transformation 148 + const processNumber = flow( 149 + mapEff((n: number) => n * 2), 150 + flatMap(n => succeed(n + 5)), 151 + mapEff(toString) 152 + ) 153 + 154 + // Use it 155 + const result1 = processNumber(succeed(10)) // "25" 156 + const result2 = processNumber(succeed(20)) // "45" 157 + ``` 158 + 159 + --- 160 + 161 + ## Building Custom Combinators 162 + 163 + Compose basic operations into domain-specific combinators: 164 + 165 + ### Conditional Execution 166 + 167 + ```typescript 168 + const when = <A, E, R>( 169 + condition: boolean, 170 + effect: Eff<A, E, R> 171 + ): Eff<A | void, E, R> => 172 + condition ? effect : succeed(undefined) 173 + 174 + // Usage 175 + pipe( 176 + when(user.isAdmin, deleteAllData()), 177 + flatMap(() => succeed("Done")) 178 + ) 179 + ``` 180 + 181 + ### Fallback Chain 182 + 183 + ```typescript 184 + const firstSuccess = <A, E, R>( 185 + effects: Array<Eff<A, E, R>> 186 + ): Eff<A, E, R> => 187 + effects.reduce((acc, eff) => 188 + pipe(acc, catchAll(() => eff)) 189 + ) 190 + 191 + // Try each in order, return first success 192 + const result = firstSuccess([ 193 + fetchFromCache(key), 194 + fetchFromApi(key), 195 + fetchFromBackup(key) 196 + ]) 197 + ``` 198 + 199 + ### Logging Wrapper 200 + 201 + ```typescript 202 + const withLogging = <A, E, R>( 203 + label: string, 204 + effect: Eff<A, E, R> 205 + ): Eff<A, E, R> => 206 + pipe( 207 + sync(() => console.log(`Starting: ${label}`)), 208 + flatMap(() => effect), 209 + flatMap(a => pipe( 210 + sync(() => console.log(`Completed: ${label}`)), 211 + mapEff(() => a) 212 + )) 213 + ) 214 + 215 + // Usage 216 + const program = withLogging("fetch user", fetchUser(id)) 217 + ``` 218 + 219 + --- 220 + 221 + ## Error Channel Composition 222 + 223 + Effects have two channels: success (`A`) and error (`E`). Both compose: 224 + 225 + ### Widening Error Types 226 + 227 + ```typescript 228 + type DbError = { _tag: "DbError"; message: string } 229 + type ApiError = { _tag: "ApiError"; status: number } 230 + 231 + // This effect can fail with DbError 232 + const getUser: Eff<User, DbError, unknown> = ... 233 + 234 + // This effect can fail with ApiError 235 + const sendEmail: Eff<void, ApiError, unknown> = ... 236 + 237 + // Composed effect can fail with either 238 + const program: Eff<void, DbError | ApiError, unknown> = pipe( 239 + getUser, 240 + flatMap(user => sendEmail(user.email)) 241 + ) 242 + ``` 243 + 244 + TypeScript unions the error types automatically. 245 + 246 + ### Recovering from Errors 247 + 248 + Use `catchAll` to handle errors and continue: 249 + 250 + ```typescript 251 + const withFallback = pipe( 252 + getFromCache(key), 253 + catchAll(error => getFromDatabase(key)) 254 + ) 255 + // Type: Eff<Value, DbError, unknown> 256 + // (CacheError is handled, DbError remains) 257 + ``` 258 + 259 + ### Transforming Errors 260 + 261 + Use `foldEff` to handle both channels: 262 + 263 + ```typescript 264 + const normalized = pipe( 265 + effect, 266 + foldEff( 267 + error => fail({ _tag: "AppError", cause: error }), 268 + value => succeed(value) 269 + ) 270 + ) 271 + ``` 272 + 273 + --- 274 + 275 + ## Common Patterns 276 + 277 + ### Sequential Operations 278 + 279 + Use `flatMap` chain or array reduce: 280 + 281 + ```typescript 282 + // Chain 283 + const sequential = pipe( 284 + step1, 285 + flatMap(() => step2), 286 + flatMap(() => step3) 287 + ) 288 + 289 + // From array 290 + const steps = [step1, step2, step3] 291 + const sequential = steps.reduce( 292 + (acc, step) => flatMap(() => step)(acc), 293 + succeed(undefined) 294 + ) 295 + ``` 296 + 297 + ### Parallel Operations 298 + 299 + Use `all` combinator: 300 + 301 + ```typescript 302 + import { all } from "purus-ts" 303 + 304 + const parallel = all([ 305 + fetchUser(id), 306 + fetchOrders(id), 307 + fetchPreferences(id) 308 + ]) 309 + // Type: Eff<[User, Order[], Preferences], Error, unknown> 310 + ``` 311 + 312 + ### Racing 313 + 314 + Use `race` for first-to-complete: 315 + 316 + ```typescript 317 + import { race } from "purus-ts" 318 + 319 + const fastest = race([ 320 + fetchFromServer1(key), 321 + fetchFromServer2(key) 322 + ]) 323 + // Returns whichever completes first 324 + ``` 325 + 326 + --- 327 + 328 + ## Key Takeaways 329 + 330 + 1. **Effects are data** - They describe computation, they don't run it 331 + 2. **flatMap sequences effects** - The fundamental composition operator 332 + 3. **pipe() enables readability** - Top-to-bottom instead of inside-out 333 + 4. **Errors short-circuit** - Failed effects skip subsequent flatMaps 334 + 5. **Build custom combinators** - Compose basics into domain operations 335 + 6. **Both channels compose** - Success types narrow, error types widen 336 + 337 + Understanding effect composition is the key to productive purus usage. Once you see effects as composable data, complex programs become pipelines of simple transformations. 338 + 339 + --- 340 + 341 + ## See Also 342 + 343 + - [Why Errors as Values?](./01-errors-as-values.md) - Railway oriented programming 344 + - [Fiber Internals](./07-fiber-internals.md) - How effects actually execute 345 + - [HTTP Client Example](../../../examples/http-client/) - Real-world effect composition
+341
docs/guides/concepts/07-fiber-internals.md
··· 1 + # Fiber Internals 2 + 3 + This article explains how purus executes effects under the hood: the trampoline pattern, stack safety, and fiber lifecycle. 4 + 5 + --- 6 + 7 + ## Why a Custom Runtime? 8 + 9 + JavaScript Promises run immediately and can't be cancelled. But purus effects are: 10 + 11 + - **Lazy** - Nothing runs until you call `runPromise` 12 + - **Cancellable** - Fibers can be interrupted mid-execution 13 + - **Stack-safe** - Deep recursion doesn't overflow the stack 14 + 15 + To achieve this, purus implements its own runtime. The key insight: separate description (Eff) from execution (Trampoline). 16 + 17 + ``` 18 + Eff (Data) Step (Interpreter) Trampoline (Execution) 19 + ------------ ----------------- ---------------------- 20 + Succeed --> Done(Success) --> Promise.resolve(value) 21 + FlatMap --> Suspended(next) --> Promise.resolve().then(...) 22 + Async --> Blocked(...) --> new Promise(...) 23 + ``` 24 + 25 + --- 26 + 27 + ## The Architecture 28 + 29 + purus execution has three layers: 30 + 31 + ### 1. Eff - The Effect AST 32 + 33 + Effects are a tree of nodes describing what to do: 34 + 35 + ```typescript 36 + type Eff<A, E, R> = 37 + | { _tag: "Succeed"; value: A } 38 + | { _tag: "Fail"; error: E } 39 + | { _tag: "FlatMap"; effect: Eff<...>; f: ... } 40 + | { _tag: "Async"; register: ... } 41 + // ... more variants 42 + ``` 43 + 44 + ### 2. Step - Interpreter Output 45 + 46 + The interpreter transforms Eff into Step, describing what to do next: 47 + 48 + ```typescript 49 + type Step<A, E> = 50 + | { _tag: "Done"; exit: Exit<A, E> } // Finished 51 + | { _tag: "Suspended"; resume: () => Step } // More work (sync) 52 + | { _tag: "Blocked"; onComplete: ...; next } // Waiting (async) 53 + ``` 54 + 55 + ### 3. Trampoline - Actual Execution 56 + 57 + The trampoline turns Steps into Promises, using the JavaScript event loop: 58 + 59 + ```typescript 60 + const trampoline = <A, E>(step: Step<A, E>): Promise<Exit<A, E>> => 61 + match(step)({ 62 + Done: ({ exit }) => Promise.resolve(exit), 63 + Suspended: ({ resume }) => Promise.resolve().then(() => trampoline(resume())), 64 + Blocked: ({ onComplete, next }) => 65 + new Promise(resolve => onComplete(() => resolve(trampoline(next())))) 66 + }) 67 + ``` 68 + 69 + --- 70 + 71 + ## The Trampoline Pattern 72 + 73 + ### The Stack Overflow Problem 74 + 75 + Consider deep recursion: 76 + 77 + ```typescript 78 + const countdown = (n: number): Eff<void, never, unknown> => 79 + n === 0 80 + ? succeed(undefined) 81 + : pipe(succeed(n), flatMap(() => countdown(n - 1))) 82 + 83 + runPromise(countdown(100000)) // Stack overflow! 84 + ``` 85 + 86 + Each `flatMap` adds a function call. Deep enough, and JavaScript's call stack overflows. 87 + 88 + ### Trampolining to the Rescue 89 + 90 + Instead of calling functions directly, we return a description of what to call: 91 + 92 + ```typescript 93 + // Instead of: f(g(h(x))) 94 + // We do: { call: h, next: { call: g, next: { call: f } } } 95 + ``` 96 + 97 + The trampoline loop processes one step at a time: 98 + 99 + ```typescript 100 + Suspended: ({ resume }) => 101 + Promise.resolve().then(() => trampoline(resume())) 102 + ``` 103 + 104 + The `Promise.resolve().then(...)` yields to the event loop, unwinding the stack before continuing. No matter how deep the effect tree, the stack stays shallow. 105 + 106 + --- 107 + 108 + ## Stack Safety in Detail 109 + 110 + ### Sync Operations 111 + 112 + For sync operations like `Succeed` or `Sync`, the interpreter returns `Suspended`: 113 + 114 + ```typescript 115 + FlatMap: ({ effect, f }) => 116 + suspended(() => 117 + interpret(effect, { 118 + onSuccess: (a) => interpret(f(a), cont, makeFiber), 119 + onFailure: cont.onFailure 120 + }, makeFiber) 121 + ) 122 + ``` 123 + 124 + The `suspended(() => ...)` wraps the continuation. The trampoline processes it: 125 + 126 + ```typescript 127 + Suspended: ({ resume }) => 128 + Promise.resolve().then(() => trampoline(resume())) 129 + ``` 130 + 131 + This yields to the event loop, keeping the stack at constant depth. 132 + 133 + ### Async Operations 134 + 135 + For async operations like `Async`, the interpreter returns `Blocked`: 136 + 137 + ```typescript 138 + Async: ({ register }) => { 139 + let result: Exit<A, E> | null = null 140 + let callback: (() => void) | null = null 141 + 142 + register((exit) => { 143 + result = exit 144 + callback?.() 145 + }) 146 + 147 + if (result !== null) { 148 + return handleExit(result) // Already completed 149 + } 150 + 151 + return blocked( 152 + cleanup, 153 + (cb) => { callback = cb }, 154 + () => handleExit(result!) 155 + ) 156 + } 157 + ``` 158 + 159 + The trampoline waits for the async operation: 160 + 161 + ```typescript 162 + Blocked: ({ onComplete, next }) => 163 + new Promise(resolve => 164 + onComplete(() => resolve(trampoline(next()))) 165 + ) 166 + ``` 167 + 168 + When the async operation completes, it calls `callback`, which resolves the Promise and continues trampolining. 169 + 170 + --- 171 + 172 + ## Fiber Lifecycle 173 + 174 + A Fiber is a lightweight thread with its own execution context: 175 + 176 + ```typescript 177 + interface Fiber<A, E> { 178 + id: string 179 + await: () => Promise<Exit<A, E>> // Wait for completion 180 + interrupt: () => void // Request cancellation 181 + join: () => Eff<A, E, unknown> // Wait as an Effect 182 + } 183 + ``` 184 + 185 + ### Creation 186 + 187 + `runFiber` creates a fiber and starts execution: 188 + 189 + ```typescript 190 + const createFiber = <A, E, R>(eff: Eff<A, E, R>, env: R): Fiber<A, E> => { 191 + const id = `fiber-${++counter}` 192 + let isInterrupted = false 193 + let exitResult: Option<Exit<A, E>> = none 194 + const awaiters: Array<(exit: Exit<A, E>) => void> = [] 195 + 196 + // Start interpretation 197 + const initialStep = interpret(eff, { 198 + env, 199 + onSuccess: (a) => done(success(a)), 200 + onFailure: (e) => done(failure(e)) 201 + }, makeFiber) 202 + 203 + // Run the trampoline 204 + trampoline(initialStep).then((exit) => { 205 + const finalExit = isInterrupted ? interrupted(id) : exit 206 + exitResult = some(finalExit) 207 + awaiters.forEach(cb => cb(finalExit)) 208 + }) 209 + 210 + return { id, await: ..., interrupt: ..., join: ... } 211 + } 212 + ``` 213 + 214 + ### Completion States 215 + 216 + A fiber completes with an `Exit<A, E>`: 217 + 218 + ```typescript 219 + type Exit<A, E> = 220 + | { _tag: "Success"; value: A } 221 + | { _tag: "Failure"; error: E } 222 + | { _tag: "Interrupted"; by: string } 223 + ``` 224 + 225 + --- 226 + 227 + ## Async Operation Handling 228 + 229 + The `async` constructor bridges callback-based APIs: 230 + 231 + ```typescript 232 + const delay = (ms: number): Eff<void, never, unknown> => 233 + async(resume => { 234 + const id = setTimeout( 235 + () => resume({ _tag: "Success", value: undefined }), 236 + ms 237 + ) 238 + return () => clearTimeout(id) // Cleanup function 239 + }) 240 + ``` 241 + 242 + ### The Flow 243 + 244 + 1. `async(register)` creates an `Async` node 245 + 2. Interpreter calls `register`, passing the `resume` callback 246 + 3. Interpreter returns `Blocked` step with the continuation 247 + 4. Trampoline creates a Promise that waits for completion 248 + 5. When the async operation finishes, it calls `resume` 249 + 6. `resume` stores the result and calls the callback 250 + 7. Trampoline continues with the next step 251 + 252 + --- 253 + 254 + ## Interruption and Cleanup 255 + 256 + Fibers can be interrupted, which triggers cleanup: 257 + 258 + ```typescript 259 + const fiber = runFiber(longRunningEffect, {}) 260 + // Later... 261 + fiber.interrupt() 262 + ``` 263 + 264 + ### How Interruption Works 265 + 266 + 1. `interrupt()` sets a flag: `isInterrupted = true` 267 + 2. When the fiber completes, the trampoline checks this flag 268 + 3. If interrupted, the Exit becomes `Interrupted` instead of Success/Failure 269 + 270 + ### Cleanup Functions 271 + 272 + The cleanup function from `async` is called when: 273 + 274 + - The fiber is interrupted 275 + - A timeout expires 276 + - A race is lost 277 + 278 + ```typescript 279 + const cancellable = async(resume => { 280 + const controller = new AbortController() 281 + 282 + fetch(url, { signal: controller.signal }) 283 + .then(r => resume({ _tag: "Success", value: r })) 284 + .catch(e => resume({ _tag: "Failure", error: e })) 285 + 286 + return () => controller.abort() // Cleanup 287 + }) 288 + ``` 289 + 290 + --- 291 + 292 + ## The Pure Interpreter 293 + 294 + The `interpret` function is pure - it takes an Eff and returns a Step: 295 + 296 + ```typescript 297 + const interpret = <A, E, R>( 298 + eff: Eff<A, E, R>, 299 + cont: Continuation<A, E, R>, 300 + makeFiber: ... 301 + ): Step<unknown, unknown> => 302 + match(eff)({ 303 + Succeed: ({ value }) => cont.onSuccess(value), 304 + Fail: ({ error }) => cont.onFailure(error), 305 + FlatMap: ({ effect, f }) => 306 + suspended(() => interpret(effect, { 307 + onSuccess: (a) => interpret(f(a), cont, makeFiber), 308 + onFailure: cont.onFailure 309 + }, makeFiber)), 310 + // ... other cases 311 + }) 312 + ``` 313 + 314 + This purity is important: 315 + 316 + - **Testable** - You can test interpretation without running effects 317 + - **Predictable** - No hidden side effects 318 + - **Composable** - Interpreters can be stacked or modified 319 + 320 + --- 321 + 322 + ## Key Takeaways 323 + 324 + 1. **Effects are data** - Eff is a tree describing what to do 325 + 2. **Interpreter is pure** - Transforms Eff to Step without side effects 326 + 3. **Trampoline executes** - The only place that touches JS runtime 327 + 4. **Stack safety via yielding** - `Promise.resolve().then()` unwinds stack 328 + 5. **Fibers manage lifecycle** - Creation, completion, interruption 329 + 6. **Cleanup enables cancellation** - The return value from `async` register 330 + 331 + Understanding the runtime helps you: 332 + - Debug unexpected behavior 333 + - Know what's safe to do in callbacks 334 + - Understand performance characteristics 335 + 336 + --- 337 + 338 + ## See Also 339 + 340 + - [Effect Composition](./06-effect-composition.md) - How effects combine 341 + - [Testing Strategies](./09-testing-strategies.md) - Using Exit for assertions
+392
docs/guides/concepts/08-dependency-injection-patterns.md
··· 1 + # Dependency Injection Patterns 2 + 3 + This article explains how to write testable code using purus's built-in dependency injection: `access`, `accessEff`, and `provide`. 4 + 5 + --- 6 + 7 + ## The Problem with Hardcoded Dependencies 8 + 9 + Consider code that directly uses external services: 10 + 11 + ```typescript 12 + const sendWelcomeEmail = async (userId: string): Promise<void> => { 13 + const user = await database.getUser(userId) // Hardcoded 14 + const template = await templates.get("welcome") // Hardcoded 15 + await emailService.send(user.email, template) // Hardcoded 16 + } 17 + ``` 18 + 19 + This is hard to test because: 20 + 21 + 1. **Can't isolate** - Tests hit real database, template service, email 22 + 2. **Can't mock** - No way to inject fake implementations 23 + 3. **Can't verify** - Hard to check what was sent without sending 24 + 25 + Dependency injection solves this by making dependencies explicit. 26 + 27 + --- 28 + 29 + ## The Reader Pattern 30 + 31 + The Reader pattern passes dependencies through function parameters: 32 + 33 + ```typescript 34 + type Env = { 35 + database: Database 36 + templates: Templates 37 + emailService: EmailService 38 + } 39 + 40 + const sendWelcomeEmail = (userId: string, env: Env): Promise<void> => { 41 + const user = await env.database.getUser(userId) 42 + const template = await env.templates.get("welcome") 43 + await env.emailService.send(user.email, template) 44 + } 45 + ``` 46 + 47 + Better, but tedious. Every function needs the `env` parameter. Call chains become: 48 + 49 + ```typescript 50 + processOrder(order, env) 51 + -> chargePayment(order, env) 52 + -> sendReceipt(order, env) 53 + -> getEmailTemplate(order, env) 54 + ``` 55 + 56 + purus automates this with the `R` type parameter. 57 + 58 + --- 59 + 60 + ## The R Type Parameter 61 + 62 + Every `Eff<A, E, R>` has three type parameters: 63 + 64 + - `A` - Success type 65 + - `E` - Error type 66 + - `R` - Required environment (dependencies) 67 + 68 + The `R` parameter is a "dependency manifest" that travels with the effect: 69 + 70 + ```typescript 71 + // This effect requires a Logger 72 + const logMessage: Eff<void, never, { logger: Logger }> 73 + 74 + // This effect requires a Database 75 + const getUser: Eff<User, DbError, { db: Database }> 76 + 77 + // Composed effect requires both 78 + const logAndFetch: Eff<User, DbError, { logger: Logger } & { db: Database }> 79 + ``` 80 + 81 + --- 82 + 83 + ## access() - Reading from Environment 84 + 85 + Use `access()` to read a value from the environment: 86 + 87 + ```typescript 88 + import { access } from "purus-ts" 89 + 90 + type Config = { 91 + apiUrl: string 92 + timeout: number 93 + } 94 + 95 + // access<R, A>(f: (r: R) => A): Eff<A, never, R> 96 + 97 + const getApiUrl = access((config: Config) => config.apiUrl) 98 + // Type: Eff<string, never, Config> 99 + 100 + const getTimeout = access((config: Config) => config.timeout) 101 + // Type: Eff<number, never, Config> 102 + ``` 103 + 104 + The returned effect requires `Config` to run. The dependency is now explicit in the type. 105 + 106 + ### Usage Pattern 107 + 108 + ```typescript 109 + const fetchFromApi = (path: string) => 110 + pipe( 111 + access((config: Config) => config.apiUrl), 112 + flatMap(baseUrl => fromPromise(() => 113 + fetch(`${baseUrl}${path}`).then(r => r.json()) 114 + )) 115 + ) 116 + // Type: Eff<unknown, unknown, Config> 117 + ``` 118 + 119 + --- 120 + 121 + ## accessEff() - Effects Using Environment 122 + 123 + When the environment access itself needs an effect, use `accessEff()`: 124 + 125 + ```typescript 126 + import { accessEff } from "purus-ts" 127 + 128 + type DbEnv = { db: Database } 129 + 130 + // accessEff<R, A, E>(f: (r: R) => Eff<A, E, R>): Eff<A, E, R> 131 + 132 + const getUser = (id: string) => 133 + accessEff((env: DbEnv) => 134 + fromPromise(() => env.db.query(`SELECT * FROM users WHERE id = ?`, [id])) 135 + ) 136 + // Type: Eff<User, unknown, DbEnv> 137 + ``` 138 + 139 + `accessEff` is for when you need the environment to construct an effect, not just read a value. 140 + 141 + --- 142 + 143 + ## provide() - Injecting Dependencies 144 + 145 + Use `provide()` to supply dependencies, removing them from requirements: 146 + 147 + ```typescript 148 + import { provide, runPromise } from "purus-ts" 149 + 150 + // provide<R>(env: R) => Eff<A, E, R> => Eff<A, E, unknown> 151 + 152 + const effect = getUser("user-123") 153 + // Type: Eff<User, unknown, DbEnv> 154 + 155 + const provided = pipe( 156 + effect, 157 + provide({ db: productionDatabase }) 158 + ) 159 + // Type: Eff<User, unknown, unknown> 160 + 161 + // Now it can run - requirements are satisfied 162 + await runPromise(provided) 163 + ``` 164 + 165 + ### Provide at the Edge 166 + 167 + Typically, you `provide` at the application boundary: 168 + 169 + ```typescript 170 + // In application code - compose effects 171 + const app = pipe( 172 + getUser(id), 173 + flatMap(user => sendEmail(user.email, "Welcome!")), 174 + flatMap(() => logEvent("user.welcomed", { userId: id })) 175 + ) 176 + // Type: Eff<void, AppError, DbEnv & EmailEnv & LoggerEnv> 177 + 178 + // At the entry point - provide all dependencies 179 + const main = pipe( 180 + app, 181 + provide({ 182 + db: productionDb, 183 + emailService: productionEmail, 184 + logger: productionLogger 185 + }) 186 + ) 187 + 188 + runPromise(main) 189 + ``` 190 + 191 + --- 192 + 193 + ## Layered Environments 194 + 195 + For complex apps, organize dependencies into layers: 196 + 197 + ```typescript 198 + // Define service interfaces 199 + type DatabaseService = { 200 + query: <T>(sql: string, params: unknown[]) => Promise<T> 201 + } 202 + 203 + type CacheService = { 204 + get: <T>(key: string) => Promise<T | null> 205 + set: <T>(key: string, value: T, ttl: number) => Promise<void> 206 + } 207 + 208 + type EmailService = { 209 + send: (to: string, subject: string, body: string) => Promise<void> 210 + } 211 + 212 + // Combine into layers 213 + type DataLayer = { 214 + db: DatabaseService 215 + cache: CacheService 216 + } 217 + 218 + type NotificationLayer = { 219 + email: EmailService 220 + } 221 + 222 + type AppEnv = DataLayer & NotificationLayer 223 + ``` 224 + 225 + ### Layer Construction 226 + 227 + Build layers from configuration: 228 + 229 + ```typescript 230 + const createDataLayer = (config: DbConfig): DataLayer => ({ 231 + db: createPostgresClient(config), 232 + cache: createRedisClient(config.redis) 233 + }) 234 + 235 + const createNotificationLayer = (config: EmailConfig): NotificationLayer => ({ 236 + email: createSendgridClient(config) 237 + }) 238 + 239 + // Combine at startup 240 + const env: AppEnv = { 241 + ...createDataLayer(dbConfig), 242 + ...createNotificationLayer(emailConfig) 243 + } 244 + ``` 245 + 246 + --- 247 + 248 + ## Testing with Mock Environments 249 + 250 + The power of DI shines in testing. Create mock implementations: 251 + 252 + ```typescript 253 + // Test doubles 254 + const mockDb: DatabaseService = { 255 + query: async (sql, params) => { 256 + if (sql.includes("users")) { 257 + return [{ id: "test-user", email: "test@example.com" }] 258 + } 259 + return [] 260 + } 261 + } 262 + 263 + const capturedEmails: Array<{ to: string; subject: string }> = [] 264 + 265 + const mockEmail: EmailService = { 266 + send: async (to, subject, body) => { 267 + capturedEmails.push({ to, subject }) 268 + } 269 + } 270 + 271 + // Test environment 272 + const testEnv: AppEnv = { 273 + db: mockDb, 274 + cache: { get: async () => null, set: async () => {} }, 275 + email: mockEmail 276 + } 277 + ``` 278 + 279 + ### Test Example 280 + 281 + ```typescript 282 + import { expect, test } from "bun:test" 283 + import { runPromiseExit } from "purus-ts" 284 + 285 + test("sendWelcomeEmail sends to correct address", async () => { 286 + capturedEmails.length = 0 // Reset 287 + 288 + const effect = pipe( 289 + sendWelcomeEmail("test-user"), 290 + provide(testEnv) 291 + ) 292 + 293 + const exit = await runPromiseExit(effect) 294 + 295 + expect(exit._tag).toBe("Success") 296 + expect(capturedEmails[0].to).toBe("test@example.com") 297 + expect(capturedEmails[0].subject).toContain("Welcome") 298 + }) 299 + ``` 300 + 301 + --- 302 + 303 + ## Advanced: Partial Provides 304 + 305 + You can provide dependencies incrementally: 306 + 307 + ```typescript 308 + type FullEnv = DbEnv & CacheEnv & LoggerEnv 309 + 310 + const effect: Eff<void, Error, FullEnv> = ... 311 + 312 + // Provide database first 313 + const withDb = pipe(effect, provide({ db: productionDb })) 314 + // Type: Eff<void, Error, CacheEnv & LoggerEnv> 315 + 316 + // Provide cache 317 + const withDbAndCache = pipe(withDb, provide({ cache: productionCache })) 318 + // Type: Eff<void, Error, LoggerEnv> 319 + 320 + // Finally provide logger 321 + const ready = pipe(withDbAndCache, provide({ logger: productionLogger })) 322 + // Type: Eff<void, Error, unknown> 323 + ``` 324 + 325 + This lets different parts of your app provide their own dependencies. 326 + 327 + --- 328 + 329 + ## Pattern: Service Modules 330 + 331 + Organize services as modules with their environment: 332 + 333 + ```typescript 334 + // users/service.ts 335 + export type UsersEnv = { usersDb: Database } 336 + 337 + export const getUser = (id: string): Eff<User, UserNotFound, UsersEnv> => 338 + accessEff((env: UsersEnv) => 339 + pipe( 340 + fromPromise(() => env.usersDb.findById(id)), 341 + flatMap(user => user ? succeed(user) : fail({ _tag: "UserNotFound", id })) 342 + ) 343 + ) 344 + 345 + export const createUser = (data: CreateUserDto): Eff<User, ValidationError, UsersEnv> => 346 + accessEff((env: UsersEnv) => 347 + fromPromise(() => env.usersDb.insert(data)) 348 + ) 349 + 350 + // orders/service.ts 351 + export type OrdersEnv = { ordersDb: Database } 352 + 353 + export const createOrder = (userId: string, items: Item[]): Eff<Order, OrderError, OrdersEnv> => 354 + ... 355 + ``` 356 + 357 + Compose at the application level: 358 + 359 + ```typescript 360 + // app.ts 361 + type AppEnv = UsersEnv & OrdersEnv & PaymentEnv 362 + 363 + const processCheckout = (userId: string, items: Item[]): Eff<Order, AppError, AppEnv> => 364 + pipe( 365 + getUser(userId), 366 + flatMap(user => createOrder(user.id, items)), 367 + flatMap(order => processPayment(order)) 368 + ) 369 + ``` 370 + 371 + --- 372 + 373 + ## Key Takeaways 374 + 375 + 1. **R parameter is a dependency manifest** - Types track what's needed 376 + 2. **access() reads from environment** - For getting config/service references 377 + 3. **accessEff() for effectful access** - When env access needs an effect 378 + 4. **provide() supplies dependencies** - Removes requirements from type 379 + 5. **Provide at the edge** - Business logic stays dependency-agnostic 380 + 6. **Mock environments for testing** - Swap implementations easily 381 + 382 + This pattern gives you: 383 + - **Type-safe DI** - Can't run effects with missing dependencies 384 + - **Testability** - Easy to inject mocks 385 + - **Flexibility** - Change implementations at runtime 386 + 387 + --- 388 + 389 + ## See Also 390 + 391 + - [Testing Strategies](./09-testing-strategies.md) - More testing patterns 392 + - [HTTP Client Example](../../../examples/http-client/) - DI in practice
+439
docs/guides/concepts/09-testing-strategies.md
··· 1 + # Testing Strategies 2 + 3 + This article covers practical patterns for testing code that uses purus: pure functions, effects, error paths, and concurrent operations. 4 + 5 + --- 6 + 7 + ## Testing Pure Functions 8 + 9 + Pure functions - no effects, no side effects - are the easiest to test: 10 + 11 + ```typescript 12 + import { expect, test } from "bun:test" 13 + import { ok, err, matchResult } from "purus-ts" 14 + 15 + // Function under test 16 + const validateEmail = (input: string): Result<Email, EmailError> => 17 + input.includes("@") ? ok(input as Email) : err({ _tag: "InvalidEmail", input }) 18 + 19 + // Tests 20 + test("validateEmail accepts valid emails", () => { 21 + const result = validateEmail("user@example.com") 22 + expect(result._tag).toBe("Ok") 23 + }) 24 + 25 + test("validateEmail rejects invalid emails", () => { 26 + const result = validateEmail("not-an-email") 27 + expect(result._tag).toBe("Err") 28 + expect(result.error._tag).toBe("InvalidEmail") 29 + }) 30 + ``` 31 + 32 + ### Testing with matchResult 33 + 34 + Use `matchResult` for cleaner assertions: 35 + 36 + ```typescript 37 + test("validateEmail returns correct error message", () => { 38 + const result = validateEmail("bad") 39 + 40 + matchResult( 41 + email => { throw new Error("Expected error") }, 42 + error => { 43 + expect(error._tag).toBe("InvalidEmail") 44 + expect(error.input).toBe("bad") 45 + } 46 + )(result) 47 + }) 48 + ``` 49 + 50 + --- 51 + 52 + ## Testing Effects with runPromiseExit 53 + 54 + For effects, use `runPromiseExit` to get the full result without throwing: 55 + 56 + ```typescript 57 + import { runPromiseExit, succeed, fail, isSuccess, isFailure } from "purus-ts" 58 + 59 + // Function under test 60 + const divideEffect = (a: number, b: number): Eff<number, DivideByZero, unknown> => 61 + b === 0 ? fail({ _tag: "DivideByZero" }) : succeed(a / b) 62 + 63 + // Tests 64 + test("divideEffect succeeds with valid inputs", async () => { 65 + const exit = await runPromiseExit(divideEffect(10, 2)) 66 + 67 + expect(exit._tag).toBe("Success") 68 + expect(exit.value).toBe(5) 69 + }) 70 + 71 + test("divideEffect fails on divide by zero", async () => { 72 + const exit = await runPromiseExit(divideEffect(10, 0)) 73 + 74 + expect(exit._tag).toBe("Failure") 75 + expect(exit.error._tag).toBe("DivideByZero") 76 + }) 77 + ``` 78 + 79 + ### Why runPromiseExit? 80 + 81 + `runPromise` throws on failure, making error testing awkward: 82 + 83 + ```typescript 84 + // Awkward - need try/catch 85 + test("bad pattern", async () => { 86 + try { 87 + await runPromise(failingEffect) 88 + throw new Error("Should have failed") 89 + } catch (e) { 90 + expect(e._tag).toBe("SomeError") 91 + } 92 + }) 93 + 94 + // Better - use runPromiseExit 95 + test("good pattern", async () => { 96 + const exit = await runPromiseExit(failingEffect) 97 + expect(exit._tag).toBe("Failure") 98 + expect(exit.error._tag).toBe("SomeError") 99 + }) 100 + ``` 101 + 102 + --- 103 + 104 + ## Testing Error Paths 105 + 106 + Errors are first-class in purus. Test them explicitly: 107 + 108 + ```typescript 109 + type FetchError = 110 + | { _tag: "NetworkError"; message: string } 111 + | { _tag: "NotFound"; id: string } 112 + | { _tag: "Unauthorized" } 113 + 114 + const fetchUser = (id: string): Eff<User, FetchError, ApiEnv> => ... 115 + 116 + test("fetchUser returns NotFound for missing user", async () => { 117 + const env = createMockEnv({ users: [] }) // Empty database 118 + 119 + const exit = await runPromiseExit(pipe( 120 + fetchUser("missing-id"), 121 + provide(env) 122 + )) 123 + 124 + expect(exit._tag).toBe("Failure") 125 + if (exit._tag === "Failure") { 126 + expect(exit.error._tag).toBe("NotFound") 127 + expect(exit.error.id).toBe("missing-id") 128 + } 129 + }) 130 + 131 + test("fetchUser returns Unauthorized without token", async () => { 132 + const env = createMockEnv({ authToken: null }) 133 + 134 + const exit = await runPromiseExit(pipe( 135 + fetchUser("user-123"), 136 + provide(env) 137 + )) 138 + 139 + expect(exit._tag).toBe("Failure") 140 + if (exit._tag === "Failure") { 141 + expect(exit.error._tag).toBe("Unauthorized") 142 + } 143 + }) 144 + ``` 145 + 146 + ### Testing Error Recovery 147 + 148 + Test that errors are handled correctly: 149 + 150 + ```typescript 151 + const withFallback = pipe( 152 + fetchUser(id), 153 + catchAll(error => 154 + error._tag === "NotFound" 155 + ? succeed(defaultUser) 156 + : fail(error) 157 + ) 158 + ) 159 + 160 + test("falls back to default user when not found", async () => { 161 + const env = createMockEnv({ users: [] }) 162 + 163 + const exit = await runPromiseExit(pipe(withFallback, provide(env))) 164 + 165 + expect(exit._tag).toBe("Success") 166 + expect(exit.value).toEqual(defaultUser) 167 + }) 168 + ``` 169 + 170 + --- 171 + 172 + ## Mock Environments 173 + 174 + Create test environments that capture calls and return controlled values: 175 + 176 + ```typescript 177 + // Test double builder 178 + const createMockDb = (data: { 179 + users?: User[] 180 + returnError?: boolean 181 + }): DatabaseService => ({ 182 + query: async <T>(sql: string, params: unknown[]): Promise<T> => { 183 + if (data.returnError) { 184 + throw new Error("Database connection failed") 185 + } 186 + if (sql.includes("users")) { 187 + return data.users as T 188 + } 189 + return [] as T 190 + } 191 + }) 192 + 193 + // Spy to capture calls 194 + type EmailSpy = { 195 + calls: Array<{ to: string; subject: string; body: string }> 196 + service: EmailService 197 + } 198 + 199 + const createEmailSpy = (): EmailSpy => { 200 + const calls: EmailSpy["calls"] = [] 201 + return { 202 + calls, 203 + service: { 204 + send: async (to, subject, body) => { 205 + calls.push({ to, subject, body }) 206 + } 207 + } 208 + } 209 + } 210 + ``` 211 + 212 + ### Using Test Doubles 213 + 214 + ```typescript 215 + test("sendWelcomeEmail calls email service with correct data", async () => { 216 + const emailSpy = createEmailSpy() 217 + const env = { 218 + db: createMockDb({ users: [{ id: "u1", email: "test@example.com" }] }), 219 + email: emailSpy.service 220 + } 221 + 222 + await runPromiseExit(pipe( 223 + sendWelcomeEmail("u1"), 224 + provide(env) 225 + )) 226 + 227 + expect(emailSpy.calls).toHaveLength(1) 228 + expect(emailSpy.calls[0].to).toBe("test@example.com") 229 + expect(emailSpy.calls[0].subject).toContain("Welcome") 230 + }) 231 + ``` 232 + 233 + --- 234 + 235 + ## Testing Concurrent Code 236 + 237 + Test fibers, races, and parallel operations: 238 + 239 + ### Testing fork/join 240 + 241 + ```typescript 242 + test("forked effect runs and can be joined", async () => { 243 + let executed = false 244 + 245 + const effect = pipe( 246 + fork(sync(() => { executed = true; return 42 })), 247 + flatMap(fiber => fiber.join()) 248 + ) 249 + 250 + const exit = await runPromiseExit(effect) 251 + 252 + expect(exit._tag).toBe("Success") 253 + expect(exit.value).toBe(42) 254 + expect(executed).toBe(true) 255 + }) 256 + ``` 257 + 258 + ### Testing race 259 + 260 + ```typescript 261 + test("race returns first to complete", async () => { 262 + const fast = pipe(sleep(10), mapEff(() => "fast")) 263 + const slow = pipe(sleep(100), mapEff(() => "slow")) 264 + 265 + const exit = await runPromiseExit(race([fast, slow])) 266 + 267 + expect(exit._tag).toBe("Success") 268 + expect(exit.value).toBe("fast") 269 + }) 270 + ``` 271 + 272 + ### Testing interruption 273 + 274 + ```typescript 275 + test("interrupted fiber returns Interrupted exit", async () => { 276 + const neverEnds = async<void, never>(resume => { 277 + // Never calls resume 278 + return () => {} 279 + }) 280 + 281 + const fiber = runFiber(neverEnds, {}) 282 + fiber.interrupt() 283 + 284 + const exit = await fiber.await() 285 + 286 + expect(exit._tag).toBe("Interrupted") 287 + }) 288 + ``` 289 + 290 + ### Testing all (parallel) 291 + 292 + ```typescript 293 + test("all collects results from parallel effects", async () => { 294 + const effects = [ 295 + succeed(1), 296 + succeed(2), 297 + succeed(3) 298 + ] 299 + 300 + const exit = await runPromiseExit(all(effects)) 301 + 302 + expect(exit._tag).toBe("Success") 303 + expect(exit.value).toEqual([1, 2, 3]) 304 + }) 305 + 306 + test("all fails fast on first error", async () => { 307 + const effects = [ 308 + succeed(1), 309 + fail({ _tag: "Error" }), 310 + succeed(3) 311 + ] 312 + 313 + const exit = await runPromiseExit(all(effects)) 314 + 315 + expect(exit._tag).toBe("Failure") 316 + }) 317 + ``` 318 + 319 + --- 320 + 321 + ## Testing Timeouts 322 + 323 + Test timeout behavior: 324 + 325 + ```typescript 326 + import { timeout } from "purus-ts" 327 + 328 + test("timeout fails slow effects", async () => { 329 + const slow = pipe(sleep(1000), mapEff(() => "done")) 330 + const withTimeout = timeout(50)(slow) 331 + 332 + const exit = await runPromiseExit(withTimeout) 333 + 334 + expect(exit._tag).toBe("Failure") 335 + expect(exit.error._tag).toBe("Timeout") 336 + }) 337 + 338 + test("timeout allows fast effects", async () => { 339 + const fast = succeed("instant") 340 + const withTimeout = timeout(1000)(fast) 341 + 342 + const exit = await runPromiseExit(withTimeout) 343 + 344 + expect(exit._tag).toBe("Success") 345 + expect(exit.value).toBe("instant") 346 + }) 347 + ``` 348 + 349 + --- 350 + 351 + ## Best Practices 352 + 353 + ### 1. Test Exit Types Directly 354 + 355 + ```typescript 356 + // Good - explicit about what we're testing 357 + expect(exit._tag).toBe("Success") 358 + expect(exit._tag).toBe("Failure") 359 + 360 + // Avoid - loses information 361 + expect(isSuccess(exit)).toBe(true) 362 + ``` 363 + 364 + ### 2. Use Type Narrowing 365 + 366 + ```typescript 367 + const exit = await runPromiseExit(effect) 368 + 369 + if (exit._tag === "Success") { 370 + // TypeScript knows exit.value exists 371 + expect(exit.value.id).toBe("expected-id") 372 + } 373 + 374 + if (exit._tag === "Failure") { 375 + // TypeScript knows exit.error exists 376 + expect(exit.error._tag).toBe("ExpectedError") 377 + } 378 + ``` 379 + 380 + ### 3. Test Error Types Exhaustively 381 + 382 + ```typescript 383 + test("handles all error types", async () => { 384 + const errorTypes: FetchError["_tag"][] = ["NetworkError", "NotFound", "Unauthorized"] 385 + 386 + for (const errorType of errorTypes) { 387 + const env = createEnvThatProduces(errorType) 388 + const exit = await runPromiseExit(pipe(fetchUser("id"), provide(env))) 389 + 390 + expect(exit._tag).toBe("Failure") 391 + if (exit._tag === "Failure") { 392 + expect(exit.error._tag).toBe(errorType) 393 + } 394 + } 395 + }) 396 + ``` 397 + 398 + ### 4. Isolate Async from Logic 399 + 400 + ```typescript 401 + // Hard to test - mixes logic with async 402 + const processOrder = (order: Order): Eff<Receipt, Error, Env> => 403 + pipe( 404 + validateOrder(order), // Pure logic 405 + flatMap(v => chargePayment(v)), // Async 406 + flatMap(p => generateReceipt(p)) // Pure logic 407 + ) 408 + 409 + // Better - test pure parts separately 410 + test("validateOrder catches invalid items", () => { 411 + const result = validateOrder(invalidOrder) 412 + expect(result._tag).toBe("Err") 413 + }) 414 + 415 + test("generateReceipt formats correctly", () => { 416 + const receipt = generateReceipt(payment) 417 + expect(receipt.total).toBe(payment.amount) 418 + }) 419 + ``` 420 + 421 + --- 422 + 423 + ## Key Takeaways 424 + 425 + 1. **runPromiseExit for effect tests** - Get Exit without try/catch 426 + 2. **Test error paths explicitly** - Errors are values, test them 427 + 3. **Mock environments via provide** - Inject test doubles 428 + 4. **Test concurrent primitives** - fork, race, all, timeout 429 + 5. **Narrow Exit types** - Use `if (exit._tag === ...)` for type safety 430 + 6. **Separate pure from effectful** - Test pure functions simply 431 + 432 + purus makes testing straightforward because effects are data. The Exit type gives you complete visibility into success, failure, and interruption. 433 + 434 + --- 435 + 436 + ## See Also 437 + 438 + - [Dependency Injection Patterns](./08-dependency-injection-patterns.md) - Creating mock environments 439 + - [Effect Composition](./06-effect-composition.md) - Understanding what you're testing
+5 -5
docs/guides/concepts/README.md
··· 82 82 83 83 --- 84 84 85 - ### Typestate Pattern 85 + ### [05 - Typestate Pattern](./05-typestate-pattern.md) 86 86 87 87 Encoding state machines in the type system. 88 88 ··· 97 97 98 98 --- 99 99 100 - ### Effect Composition 100 + ### [06 - Effect Composition](./06-effect-composition.md) 101 101 102 102 Building complex effects from simple building blocks. 103 103 ··· 112 112 113 113 --- 114 114 115 - ### Fiber Internals 115 + ### [07 - Fiber Internals](./07-fiber-internals.md) 116 116 117 117 How the purus runtime executes effects. 118 118 ··· 127 127 128 128 --- 129 129 130 - ### Dependency Injection Patterns 130 + ### [08 - Dependency Injection Patterns](./08-dependency-injection-patterns.md) 131 131 132 132 Testable code without frameworks. 133 133 ··· 142 142 143 143 --- 144 144 145 - ### Testing Strategies 145 + ### [09 - Testing Strategies](./09-testing-strategies.md) 146 146 147 147 Unit testing code that uses purus. 148 148