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 chapters 3-10

+2540
+269
docs/guides/tutorial/03-typed-errors-with-result.md
··· 1 + # Chapter 3: Typed Errors with Result 2 + 3 + In Chapter 1, we saw why exceptions break TypeScript's type safety. In Chapter 2, we used Effects for async operations. But what about synchronous code that can fail? 4 + 5 + This chapter introduces `Result<T, E>` - the foundation of typed error handling. 6 + 7 + --- 8 + 9 + ## The Problem: Unknown Failure Modes 10 + 11 + Consider these common patterns: 12 + 13 + ```typescript 14 + // Pattern 1: Return undefined 15 + const find = (arr: number[], target: number): number | undefined => 16 + arr.find(x => x === target) 17 + 18 + // Caller: Was it not found, or was the value actually undefined? 19 + ``` 20 + 21 + ```typescript 22 + // Pattern 2: Throw an exception 23 + const parse = (json: string): User => { 24 + const data = JSON.parse(json) // Can throw! 25 + return data as User 26 + } 27 + 28 + // Caller: The type says User, but it lies 29 + ``` 30 + 31 + Neither approach tells you *what* can go wrong. The type system is blind to failure. 32 + 33 + --- 34 + 35 + ## The Solution: Result<T, E> 36 + 37 + `Result<T, E>` is a discriminated union with two variants: 38 + 39 + ```typescript 40 + type Ok<T> = { readonly _tag: "Ok"; readonly value: T } 41 + type Err<E> = { readonly _tag: "Err"; readonly error: E } 42 + type Result<T, E> = Ok<T> | Err<E> 43 + ``` 44 + 45 + The type signature tells the truth: 46 + 47 + ```typescript 48 + import { type Result, ok, err } from "purus-ts" 49 + 50 + type ParseError = { _tag: "ParseError"; input: string } 51 + 52 + const parseNumber = (s: string): Result<number, ParseError> => { 53 + const n = Number(s) 54 + return Number.isNaN(n) 55 + ? err({ _tag: "ParseError", input: s }) 56 + : ok(n) 57 + } 58 + ``` 59 + 60 + Now callers know exactly what can fail. No surprises. 61 + 62 + --- 63 + 64 + ## Creating Results 65 + 66 + ### ok() and err() 67 + 68 + ```typescript 69 + import { ok, err } from "purus-ts" 70 + 71 + const success = ok(42) // Ok<number> 72 + const failure = err("oops") // Err<string> 73 + ``` 74 + 75 + ### tryCatch() - Bridging Exceptions 76 + 77 + When calling code that throws, use `tryCatch`: 78 + 79 + ```typescript 80 + import { tryCatch, mapResult } from "purus-ts" 81 + 82 + const parseJSON = (s: string) => 83 + tryCatch(() => JSON.parse(s)) 84 + // Result<unknown, unknown> 85 + ``` 86 + 87 + The error is `unknown` because JavaScript can throw anything. You can refine it: 88 + 89 + ```typescript 90 + import { pipe, mapErr } from "purus-ts" 91 + 92 + type JSONError = { _tag: "JSONError"; message: string } 93 + 94 + const safeParseJSON = (s: string) => 95 + pipe( 96 + tryCatch(() => JSON.parse(s)), 97 + mapErr((e): JSONError => ({ 98 + _tag: "JSONError", 99 + message: e instanceof Error ? e.message : String(e) 100 + })) 101 + ) 102 + ``` 103 + 104 + --- 105 + 106 + ## Transforming Results 107 + 108 + ### mapResult() - Transform Success 109 + 110 + ```typescript 111 + import { pipe, ok, err, mapResult } from "purus-ts" 112 + 113 + pipe(ok(10), mapResult(n => n * 2)) // Ok(20) 114 + pipe(err("oops"), mapResult(n => n * 2)) // Err("oops") unchanged 115 + ``` 116 + 117 + ### mapErr() - Transform Errors 118 + 119 + ```typescript 120 + import { pipe, err, mapErr } from "purus-ts" 121 + 122 + pipe( 123 + err("raw error"), 124 + mapErr(msg => ({ _tag: "Formatted", message: msg })) 125 + ) 126 + ``` 127 + 128 + ### chainResult() - Sequence Operations 129 + 130 + This is the core composition operator. Each step can fail: 131 + 132 + ```typescript 133 + import { pipe, ok, chainResult, type Result } from "purus-ts" 134 + 135 + type MathError = { _tag: "DivisionByZero" } 136 + 137 + const divide = (a: number, b: number): Result<number, MathError> => 138 + b === 0 ? err({ _tag: "DivisionByZero" }) : ok(a / b) 139 + 140 + const result = pipe( 141 + ok(100), 142 + chainResult(n => divide(n, 5)), // Ok(20) 143 + chainResult(n => divide(n, 4)), // Ok(5) 144 + chainResult(n => divide(n, 0)) // Err(DivisionByZero) 145 + ) 146 + // Subsequent steps are skipped after the error 147 + ``` 148 + 149 + --- 150 + 151 + ## Unwrapping Results 152 + 153 + ### unwrapOr() - Get Value or Default 154 + 155 + ```typescript 156 + import { pipe, ok, err, unwrapOr } from "purus-ts" 157 + 158 + pipe(ok(42), unwrapOr(0)) // 42 159 + pipe(err("oops"), unwrapOr(0)) // 0 160 + ``` 161 + 162 + ### matchResult() - Handle Both Cases 163 + 164 + ```typescript 165 + import { matchResult } from "purus-ts" 166 + 167 + const message = matchResult( 168 + value => `Success: ${value}`, 169 + error => `Error: ${error._tag}` 170 + )(result) 171 + ``` 172 + 173 + ### Type Guards 174 + 175 + ```typescript 176 + import { isOk, isErr } from "purus-ts" 177 + 178 + if (isOk(result)) { 179 + console.log(result.value) // TypeScript knows it's Ok 180 + } 181 + 182 + if (isErr(result)) { 183 + console.log(result.error) // TypeScript knows it's Err 184 + } 185 + ``` 186 + 187 + --- 188 + 189 + ## Exercise: Form Validator 190 + 191 + Build a validator for user registration: 192 + 193 + ```typescript 194 + import { type Result, ok, err, pipe, chainResult } from "purus-ts" 195 + 196 + type ValidationError = 197 + | { _tag: "EmptyField"; field: string } 198 + | { _tag: "TooShort"; field: string; min: number } 199 + | { _tag: "InvalidEmail"; value: string } 200 + 201 + type RegistrationInput = { 202 + username: string 203 + password: string 204 + email: string 205 + } 206 + 207 + type ValidRegistration = { 208 + username: string 209 + password: string 210 + email: string 211 + } 212 + 213 + // Implement these validators: 214 + const validateUsername = (s: string): Result<string, ValidationError> => 215 + s.length === 0 216 + ? err({ _tag: "EmptyField", field: "username" }) 217 + : s.length < 3 218 + ? err({ _tag: "TooShort", field: "username", min: 3 }) 219 + : ok(s) 220 + 221 + const validatePassword = (s: string): Result<string, ValidationError> => 222 + s.length === 0 223 + ? err({ _tag: "EmptyField", field: "password" }) 224 + : s.length < 8 225 + ? err({ _tag: "TooShort", field: "password", min: 8 }) 226 + : ok(s) 227 + 228 + const validateEmail = (s: string): Result<string, ValidationError> => 229 + s.length === 0 230 + ? err({ _tag: "EmptyField", field: "email" }) 231 + : !s.includes("@") 232 + ? err({ _tag: "InvalidEmail", value: s }) 233 + : ok(s) 234 + 235 + // Compose them: 236 + const validateRegistration = (input: RegistrationInput): Result<ValidRegistration, ValidationError> => 237 + pipe( 238 + validateUsername(input.username), 239 + chainResult(username => 240 + pipe( 241 + validatePassword(input.password), 242 + chainResult(password => 243 + pipe( 244 + validateEmail(input.email), 245 + mapResult(email => ({ username, password, email })) 246 + ) 247 + ) 248 + ) 249 + ) 250 + ) 251 + ``` 252 + 253 + --- 254 + 255 + ## Key Takeaways 256 + 257 + 1. **Result<T, E> makes errors visible** - The type tells you what can fail 258 + 2. **ok() and err() create Results** - Simple constructors 259 + 3. **tryCatch() bridges exceptions** - Wrap throwing code safely 260 + 4. **chainResult() composes operations** - Errors short-circuit the pipeline 261 + 5. **matchResult() handles both cases** - Explicit success and error paths 262 + 263 + --- 264 + 265 + ## What's Next 266 + 267 + Result handles synchronous errors. But what about values that might not exist at all? In the next chapter, we'll explore `Option<T>` for nullable value handling. 268 + 269 + [Continue to Chapter 4: Optional Values with Option →](./04-optional-values-with-option.md)
+263
docs/guides/tutorial/04-optional-values-with-option.md
··· 1 + # Chapter 4: Optional Values with Option 2 + 3 + In the previous chapter, we used `Result<T, E>` for operations that can fail with an error. But sometimes there's no error - a value simply might not exist. 4 + 5 + This chapter introduces `Option<T>` for composable null handling. 6 + 7 + --- 8 + 9 + ## The Problem: Null and Undefined 10 + 11 + TypeScript helps with strict null checks, but handling them is verbose: 12 + 13 + ```typescript 14 + const user = users.get(id) // User | undefined 15 + 16 + // Verbose null checking 17 + if (user !== undefined) { 18 + const email = user.email 19 + if (email !== undefined) { 20 + console.log(email.toLowerCase()) 21 + } 22 + } 23 + ``` 24 + 25 + And chaining gets worse: 26 + 27 + ```typescript 28 + // Deeply nested optional access 29 + const street = user?.address?.street?.name ?? "Unknown" 30 + ``` 31 + 32 + Optional chaining (`?.`) helps, but: 33 + - It doesn't compose with other operations 34 + - You can't abstract over the "maybe" pattern 35 + - Error handling and optional handling use different patterns 36 + 37 + --- 38 + 39 + ## The Solution: Option<T> 40 + 41 + `Option<T>` is a discriminated union representing "maybe a value": 42 + 43 + ```typescript 44 + type Some<T> = { readonly _tag: "Some"; readonly value: T } 45 + type None = { readonly _tag: "None" } 46 + type Option<T> = Some<T> | None 47 + ``` 48 + 49 + It's like `T | undefined`, but composable: 50 + 51 + ```typescript 52 + import { type Option, some, none, mapOption, pipe } from "purus-ts" 53 + 54 + const findUser = (id: string): Option<User> => 55 + users.has(id) ? some(users.get(id)!) : none 56 + 57 + const getEmail = (user: User): Option<string> => 58 + user.email ? some(user.email) : none 59 + 60 + // Compose them cleanly 61 + const result = pipe( 62 + findUser("123"), 63 + flatMapOption(getEmail), 64 + mapOption(email => email.toLowerCase()) 65 + ) 66 + ``` 67 + 68 + --- 69 + 70 + ## Creating Options 71 + 72 + ### some() and none 73 + 74 + ```typescript 75 + import { some, none } from "purus-ts" 76 + 77 + const present = some(42) // Some<number> 78 + const absent: Option<number> = none // None 79 + ``` 80 + 81 + ### fromNullable() - Bridge from Nullable 82 + 83 + Convert existing nullable values: 84 + 85 + ```typescript 86 + import { fromNullable } from "purus-ts" 87 + 88 + fromNullable(42) // Some(42) 89 + fromNullable(null) // None 90 + fromNullable(undefined) // None 91 + fromNullable(0) // Some(0) - falsy but not null 92 + fromNullable("") // Some("") - falsy but not null 93 + ``` 94 + 95 + --- 96 + 97 + ## Transforming Options 98 + 99 + ### mapOption() - Transform the Value 100 + 101 + ```typescript 102 + import { pipe, some, none, mapOption } from "purus-ts" 103 + 104 + pipe(some(10), mapOption(n => n * 2)) // Some(20) 105 + pipe(none, mapOption(n => n * 2)) // None unchanged 106 + ``` 107 + 108 + ### flatMapOption() - Chain Optional Operations 109 + 110 + Use this when the transformation itself returns an Option: 111 + 112 + ```typescript 113 + import { pipe, some, flatMapOption, type Option } from "purus-ts" 114 + 115 + const parsePositive = (s: string): Option<number> => { 116 + const n = Number(s) 117 + return n > 0 ? some(n) : none 118 + } 119 + 120 + const result = pipe( 121 + some("42"), 122 + flatMapOption(parsePositive) 123 + ) 124 + // Some(42) 125 + 126 + const invalid = pipe( 127 + some("-5"), 128 + flatMapOption(parsePositive) 129 + ) 130 + // None 131 + ``` 132 + 133 + --- 134 + 135 + ## Unwrapping Options 136 + 137 + ### getOrElse() - Get Value or Default 138 + 139 + ```typescript 140 + import { pipe, some, none, getOrElse } from "purus-ts" 141 + 142 + pipe(some(42), getOrElse(0)) // 42 143 + pipe(none, getOrElse(0)) // 0 144 + ``` 145 + 146 + ### matchOption() - Handle Both Cases 147 + 148 + ```typescript 149 + import { matchOption } from "purus-ts" 150 + 151 + const message = matchOption( 152 + value => `Found: ${value}`, 153 + () => "Not found" 154 + )(option) 155 + ``` 156 + 157 + ### toNullable() - Convert Back 158 + 159 + When you need to interop with code expecting nullable: 160 + 161 + ```typescript 162 + import { toNullable, some, none } from "purus-ts" 163 + 164 + toNullable(some(42)) // 42 165 + toNullable(none) // null 166 + ``` 167 + 168 + ### Type Guards 169 + 170 + ```typescript 171 + import { isSome, isNone } from "purus-ts" 172 + 173 + if (isSome(option)) { 174 + console.log(option.value) // TypeScript knows it's Some 175 + } 176 + ``` 177 + 178 + --- 179 + 180 + ## Option vs Result 181 + 182 + When to use which? 183 + 184 + | Use Case | Type | 185 + |----------|------| 186 + | Value might not exist | `Option<T>` | 187 + | Operation can fail with info | `Result<T, E>` | 188 + | Lookup in a collection | `Option<T>` | 189 + | Validation with error message | `Result<T, ValidationError>` | 190 + | First match in a list | `Option<T>` | 191 + | Parsing user input | `Result<T, ParseError>` | 192 + 193 + Rule of thumb: If you need to know *why* something failed, use Result. If you just need to know *whether* it exists, use Option. 194 + 195 + --- 196 + 197 + ## Exercise: Safe Property Access 198 + 199 + Build a safe accessor for nested objects: 200 + 201 + ```typescript 202 + import { type Option, some, none, pipe, flatMapOption, mapOption } from "purus-ts" 203 + 204 + type Address = { 205 + street?: { 206 + name?: string 207 + number?: number 208 + } 209 + city?: string 210 + } 211 + 212 + type User = { 213 + name: string 214 + address?: Address 215 + } 216 + 217 + // Safe accessors 218 + const getAddress = (user: User): Option<Address> => 219 + user.address ? some(user.address) : none 220 + 221 + const getStreet = (address: Address): Option<Address["street"]> => 222 + address.street ? some(address.street) : none 223 + 224 + const getStreetName = (street: NonNullable<Address["street"]>): Option<string> => 225 + street.name ? some(street.name) : none 226 + 227 + // Compose them 228 + const getUserStreetName = (user: User): Option<string> => 229 + pipe( 230 + getAddress(user), 231 + flatMapOption(getStreet), 232 + flatMapOption(getStreetName) 233 + ) 234 + 235 + // Usage 236 + const user1: User = { 237 + name: "Alice", 238 + address: { street: { name: "Main St", number: 123 }, city: "NYC" } 239 + } 240 + 241 + const user2: User = { name: "Bob" } 242 + 243 + pipe(getUserStreetName(user1), getOrElse("Unknown")) // "Main St" 244 + pipe(getUserStreetName(user2), getOrElse("Unknown")) // "Unknown" 245 + ``` 246 + 247 + --- 248 + 249 + ## Key Takeaways 250 + 251 + 1. **Option<T> represents "maybe a value"** - Some or None 252 + 2. **fromNullable() bridges nullable values** - null/undefined become None 253 + 3. **mapOption() transforms present values** - None passes through 254 + 4. **flatMapOption() chains optional operations** - Compose lookups 255 + 5. **getOrElse() provides defaults** - Extract with fallback 256 + 257 + --- 258 + 259 + ## What's Next 260 + 261 + We've been using the `_tag` field to distinguish variants. In the next chapter, we'll explore pattern matching - the elegant way to handle discriminated unions exhaustively. 262 + 263 + [Continue to Chapter 5: Pattern Matching →](./05-pattern-matching.md)
+253
docs/guides/tutorial/05-pattern-matching.md
··· 1 + # Chapter 5: Pattern Matching 2 + 3 + Throughout this tutorial, we've used the `_tag` field to distinguish between variants. This chapter shows you the elegant way to handle these discriminated unions with exhaustive pattern matching. 4 + 5 + --- 6 + 7 + ## The Problem: Switch Statements 8 + 9 + Consider a typical switch statement: 10 + 11 + ```typescript 12 + type Shape = 13 + | { _tag: "Circle"; radius: number } 14 + | { _tag: "Rectangle"; width: number; height: number } 15 + | { _tag: "Triangle"; base: number; height: number } 16 + 17 + const area = (shape: Shape): number => { 18 + switch (shape._tag) { 19 + case "Circle": 20 + return Math.PI * shape.radius ** 2 21 + case "Rectangle": 22 + return shape.width * shape.height 23 + // Oops, forgot Triangle! 24 + } 25 + } 26 + ``` 27 + 28 + TypeScript won't warn you about the missing case (unless you enable `noImplicitReturns`). Add a new variant later? Good luck finding all the switch statements that need updating. 29 + 30 + --- 31 + 32 + ## The Solution: match() 33 + 34 + `match()` forces exhaustive handling: 35 + 36 + ```typescript 37 + import { match } from "purus-ts" 38 + 39 + const area = (shape: Shape): number => 40 + match(shape)({ 41 + Circle: ({ radius }) => Math.PI * radius ** 2, 42 + Rectangle: ({ width, height }) => width * height, 43 + Triangle: ({ base, height }) => (base * height) / 2, 44 + }) 45 + ``` 46 + 47 + Try removing the Triangle case: 48 + 49 + ```typescript 50 + const area = (shape: Shape): number => 51 + match(shape)({ 52 + Circle: ({ radius }) => Math.PI * radius ** 2, 53 + Rectangle: ({ width, height }) => width * height, 54 + // Error: Property 'Triangle' is missing in type... 55 + }) 56 + ``` 57 + 58 + TypeScript catches it at compile time. 59 + 60 + --- 61 + 62 + ## How match() Works 63 + 64 + The function signature enforces that you provide a handler for every variant: 65 + 66 + ```typescript 67 + match<T extends { _tag: string }>(value: T) => 68 + <R>(cases: { [K in T["_tag"]]: (v: Extract<T, { _tag: K }>) => R }): R 69 + ``` 70 + 71 + Breaking this down: 72 + - `T` must have a `_tag` field (discriminated union) 73 + - `cases` must have a key for every possible `_tag` value 74 + - Each handler receives the correctly narrowed type 75 + 76 + --- 77 + 78 + ## Pattern Matching Variants 79 + 80 + ### matchOr() - Partial Matching with Default 81 + 82 + When you only care about some cases: 83 + 84 + ```typescript 85 + import { matchOr } from "purus-ts" 86 + 87 + type HttpStatus = 88 + | { _tag: "Ok"; data: unknown } 89 + | { _tag: "NotFound" } 90 + | { _tag: "ServerError"; code: number } 91 + | { _tag: "Timeout" } 92 + | { _tag: "RateLimited"; retryAfter: number } 93 + 94 + const message = matchOr("Unknown error")(status)({ 95 + Ok: () => "Success", 96 + NotFound: () => "Resource not found", 97 + }) 98 + // ServerError, Timeout, RateLimited all return "Unknown error" 99 + ``` 100 + 101 + ### when() - Guard-Based Matching 102 + 103 + For matching on conditions rather than tags: 104 + 105 + ```typescript 106 + import { when } from "purus-ts" 107 + 108 + const classify = (n: number): string => 109 + when(n)( 110 + [x => x < 0, () => "negative"], 111 + [x => x === 0, () => "zero"], 112 + [x => x < 10, () => "small positive"], 113 + )(() => "large positive") 114 + 115 + classify(-5) // "negative" 116 + classify(0) // "zero" 117 + classify(7) // "small positive" 118 + classify(100) // "large positive" 119 + ``` 120 + 121 + Guards are checked in order - first match wins. 122 + 123 + ### matchLiteral() - Literal Union Matching 124 + 125 + For string/number/boolean literals without `_tag`: 126 + 127 + ```typescript 128 + import { matchLiteral } from "purus-ts" 129 + 130 + type Direction = "north" | "south" | "east" | "west" 131 + 132 + const dx = (dir: Direction): number => 133 + matchLiteral(dir)({ 134 + north: 0, 135 + south: 0, 136 + east: 1, 137 + west: -1, 138 + }) 139 + 140 + const dy = (dir: Direction): number => 141 + matchLiteral(dir)({ 142 + north: -1, 143 + south: 1, 144 + east: 0, 145 + west: 0, 146 + }) 147 + ``` 148 + 149 + With default: 150 + 151 + ```typescript 152 + type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun" 153 + 154 + const dayType = (day: Day): string => 155 + matchLiteral(day)({ 156 + sat: "weekend", 157 + sun: "weekend", 158 + }, "weekday") 159 + ``` 160 + 161 + --- 162 + 163 + ## Why _tag? 164 + 165 + You might wonder: why use `_tag` instead of `type` or `kind`? 166 + 167 + Convention. The TypeScript ecosystem has settled on `_tag`: 168 + - The underscore indicates it's "internal" (not business data) 169 + - It's short and consistent 170 + - Libraries like fp-ts, Effect, and purus all use it 171 + 172 + You can use any discriminant field, but `_tag` is the standard. 173 + 174 + --- 175 + 176 + ## Matching with Result and Option 177 + 178 + The specialized matchers are syntactic sugar: 179 + 180 + ```typescript 181 + import { matchResult, matchOption } from "purus-ts" 182 + 183 + // These are equivalent: 184 + matchResult( 185 + value => `Success: ${value}`, 186 + error => `Error: ${error}` 187 + )(result) 188 + 189 + match(result)({ 190 + Ok: ({ value }) => `Success: ${value}`, 191 + Err: ({ error }) => `Error: ${error}`, 192 + }) 193 + ``` 194 + 195 + Use whichever reads better in context. 196 + 197 + --- 198 + 199 + ## Exercise: Error Handler 200 + 201 + Build an exhaustive error handler: 202 + 203 + ```typescript 204 + import { match } from "purus-ts" 205 + 206 + type AppError = 207 + | { _tag: "NetworkError"; url: string; status: number } 208 + | { _tag: "ValidationError"; field: string; message: string } 209 + | { _tag: "AuthError"; reason: "expired" | "invalid" | "missing" } 210 + | { _tag: "NotFound"; resource: string; id: string } 211 + 212 + const formatError = (error: AppError): string => 213 + match(error)({ 214 + NetworkError: ({ url, status }) => 215 + `Network request to ${url} failed with status ${status}`, 216 + ValidationError: ({ field, message }) => 217 + `Validation failed for ${field}: ${message}`, 218 + AuthError: ({ reason }) => 219 + matchLiteral(reason)({ 220 + expired: "Your session has expired. Please log in again.", 221 + invalid: "Invalid credentials.", 222 + missing: "Please log in to continue.", 223 + }), 224 + NotFound: ({ resource, id }) => 225 + `${resource} with ID ${id} was not found`, 226 + }) 227 + 228 + const suggestAction = (error: AppError): string => 229 + match(error)({ 230 + NetworkError: () => "Check your internet connection and try again.", 231 + ValidationError: ({ field }) => `Please correct the ${field} field.`, 232 + AuthError: () => "Please log in again.", 233 + NotFound: () => "The item may have been deleted.", 234 + }) 235 + ``` 236 + 237 + --- 238 + 239 + ## Key Takeaways 240 + 241 + 1. **match() is exhaustive** - Forget a case, get a compile error 242 + 2. **Each handler gets the narrowed type** - Full type safety inside handlers 243 + 3. **matchOr() for partial matches** - When you need a default 244 + 4. **when() for guards** - Match on conditions, not tags 245 + 5. **matchLiteral() for primitives** - String/number/boolean unions 246 + 247 + --- 248 + 249 + ## What's Next 250 + 251 + We've been creating distinct types with `_tag`. But what about primitives? How do you prevent swapping `UserId` and `OrderId` when both are strings? The next chapter introduces branded types. 252 + 253 + [Continue to Chapter 6: Branded Types →](./06-branded-types.md)
+283
docs/guides/tutorial/06-branded-types.md
··· 1 + # Chapter 6: Branded Types 2 + 3 + We've used discriminated unions to distinguish between variants of a type. But what about primitives? A `UserId` and an `OrderId` are both strings - how do you prevent mixing them up? 4 + 5 + This chapter introduces branded types for compile-time primitive distinction. 6 + 7 + --- 8 + 9 + ## The Problem: Type Aliases Are Just Aliases 10 + 11 + ```typescript 12 + type UserId = string 13 + type OrderId = string 14 + 15 + const getUser = (id: UserId): User => { /* ... */ } 16 + const getOrder = (id: OrderId): Order => { /* ... */ } 17 + 18 + const userId: UserId = "user-123" 19 + const orderId: OrderId = "order-456" 20 + 21 + // This compiles! Both are just strings. 22 + getUser(orderId) // Bug: passing OrderId where UserId expected 23 + getOrder(userId) // Bug: passing UserId where OrderId expected 24 + ``` 25 + 26 + TypeScript's type aliases don't create distinct types. They're documentation, not enforcement. 27 + 28 + --- 29 + 30 + ## The Solution: Branded Types 31 + 32 + A branded type adds a phantom "brand" that only exists at the type level: 33 + 34 + ```typescript 35 + import { type Branded, brand } from "purus-ts" 36 + 37 + type UserId = Branded<string, "UserId"> 38 + type OrderId = Branded<string, "OrderId"> 39 + 40 + const UserId = (s: string): UserId => brand(s) 41 + const OrderId = (s: string): OrderId => brand(s) 42 + ``` 43 + 44 + Now they're incompatible: 45 + 46 + ```typescript 47 + const userId = UserId("user-123") 48 + const orderId = OrderId("order-456") 49 + 50 + getUser(orderId) 51 + // ^^^^^^^ 52 + // Error: Argument of type 'OrderId' is not assignable 53 + // to parameter of type 'UserId' 54 + ``` 55 + 56 + At runtime, both are still strings. The brand only exists in the type system. 57 + 58 + --- 59 + 60 + ## How It Works 61 + 62 + ```typescript 63 + // The brand type uses a unique symbol 64 + declare const __brand: unique symbol 65 + type Brand<B> = { [__brand]: B } 66 + type Branded<T, B> = T & Brand<B> 67 + ``` 68 + 69 + When you write `Branded<string, "UserId">`, TypeScript sees: 70 + 71 + ```typescript 72 + string & { [__brand]: "UserId" } 73 + ``` 74 + 75 + An `OrderId` is: 76 + 77 + ```typescript 78 + string & { [__brand]: "OrderId" } 79 + ``` 80 + 81 + These aren't assignable to each other because the brand values differ. 82 + 83 + --- 84 + 85 + ## Smart Constructors 86 + 87 + The `brand()` function does no validation. For safety, create smart constructors: 88 + 89 + ```typescript 90 + import { type Option, some, none, type Branded, brand } from "purus-ts" 91 + 92 + type Email = Branded<string, "Email"> 93 + 94 + const Email = (s: string): Option<Email> => 95 + s.includes("@") ? some(brand(s)) : none 96 + 97 + // Usage 98 + const valid = Email("user@example.com") // Some(Email) 99 + const invalid = Email("not-an-email") // None 100 + ``` 101 + 102 + For detailed errors, return Result: 103 + 104 + ```typescript 105 + import { type Result, ok, err, type Branded, brand } from "purus-ts" 106 + 107 + type EmailError = { _tag: "InvalidEmail"; value: string } 108 + 109 + const Email = (s: string): Result<Email, EmailError> => 110 + s.includes("@") 111 + ? ok(brand(s)) 112 + : err({ _tag: "InvalidEmail", value: s }) 113 + ``` 114 + 115 + --- 116 + 117 + ## Refined Types 118 + 119 + purus includes `Refined<T, R>` for common numeric constraints: 120 + 121 + ```typescript 122 + import { positive, nonNegative, integer, normalized } from "purus-ts" 123 + 124 + // These return Option<Refined<number, Tag>> 125 + const age = positive(25) // Some(Positive number) 126 + const invalid = positive(-5) // None 127 + 128 + const count = integer(10) // Some(Integer) 129 + const notInt = integer(10.5) // None 130 + 131 + const ratio = normalized(0.75) // Some(Normalized 0-1) 132 + const outOfRange = normalized(1.5) // None 133 + ``` 134 + 135 + Use refined types for function parameters that require guarantees: 136 + 137 + ```typescript 138 + import { type Refined, type Positive } from "purus-ts" 139 + 140 + // This function ONLY accepts positive numbers 141 + const sqrt = (n: Refined<number, Positive>): number => 142 + Math.sqrt(n) 143 + 144 + // Can't call with unvalidated number 145 + sqrt(25) // Error: number is not Refined<number, Positive> 146 + 147 + // Must validate first 148 + import { positive, matchOption } from "purus-ts" 149 + 150 + const result = matchOption( 151 + n => sqrt(n), 152 + () => 0 153 + )(positive(25)) 154 + ``` 155 + 156 + --- 157 + 158 + ## Real-World Patterns 159 + 160 + ### Domain IDs 161 + 162 + ```typescript 163 + type UserId = Branded<string, "UserId"> 164 + type TeamId = Branded<string, "TeamId"> 165 + type ProjectId = Branded<string, "ProjectId"> 166 + 167 + const UserId = (s: string): UserId => brand(s) 168 + const TeamId = (s: string): TeamId => brand(s) 169 + const ProjectId = (s: string): ProjectId => brand(s) 170 + 171 + // Can't mix them up 172 + const addUserToTeam = (userId: UserId, teamId: TeamId): void => { 173 + // ... 174 + } 175 + ``` 176 + 177 + ### Validated Strings 178 + 179 + ```typescript 180 + type Url = Branded<string, "Url"> 181 + 182 + // Note: try/catch is an allowed exception to "no early returns" 183 + // because it bridges impure JavaScript APIs 184 + const Url = (s: string): Option<Url> => { 185 + try { 186 + return (new URL(s), some(brand(s))) 187 + } catch { 188 + return none 189 + } 190 + } 191 + ``` 192 + 193 + ### Currency Safety 194 + 195 + ```typescript 196 + type USD = Branded<number, "USD"> 197 + type EUR = Branded<number, "EUR"> 198 + 199 + const USD = (n: number): USD => brand(n) 200 + const EUR = (n: number): EUR => brand(n) 201 + 202 + const addUSD = (a: USD, b: USD): USD => brand(a + b) 203 + 204 + const price1 = USD(100) 205 + const price2 = EUR(85) 206 + 207 + addUSD(price1, price2) 208 + // ^^^^^^ 209 + // Error: Argument of type 'EUR' is not assignable to type 'USD' 210 + ``` 211 + 212 + --- 213 + 214 + ## Exercise: Order System 215 + 216 + Build a type-safe order system: 217 + 218 + ```typescript 219 + import { type Branded, type Option, brand, some, none, pipe, flatMapOption, mapOption } from "purus-ts" 220 + 221 + // Branded IDs 222 + type CustomerId = Branded<string, "CustomerId"> 223 + type ProductId = Branded<string, "ProductId"> 224 + type OrderId = Branded<string, "OrderId"> 225 + 226 + const CustomerId = (s: string): CustomerId => brand(s) 227 + const ProductId = (s: string): ProductId => brand(s) 228 + const OrderId = (s: string): OrderId => brand(s) 229 + 230 + // Data structures using branded types 231 + type Customer = { 232 + id: CustomerId 233 + name: string 234 + email: string 235 + } 236 + 237 + type Product = { 238 + id: ProductId 239 + name: string 240 + price: number 241 + } 242 + 243 + type Order = { 244 + id: OrderId 245 + customerId: CustomerId 246 + items: Array<{ productId: ProductId; quantity: number }> 247 + } 248 + 249 + // Type-safe lookups 250 + const customers = new Map<string, Customer>() 251 + const products = new Map<string, Product>() 252 + const orders = new Map<string, Order>() 253 + 254 + const findCustomer = (id: CustomerId): Option<Customer> => 255 + customers.has(id) ? some(customers.get(id)!) : none 256 + 257 + const findProduct = (id: ProductId): Option<Product> => 258 + products.has(id) ? some(products.get(id)!) : none 259 + 260 + const findOrder = (id: OrderId): Option<Order> => 261 + orders.has(id) ? some(orders.get(id)!) : none 262 + 263 + // This would be a compile error: 264 + // findCustomer(ProductId("prod-1")) // Error! 265 + ``` 266 + 267 + --- 268 + 269 + ## Key Takeaways 270 + 271 + 1. **Type aliases don't prevent mix-ups** - They're just documentation 272 + 2. **Branded<T, B> creates distinct types** - Incompatible at compile time 273 + 3. **brand() is unchecked** - Use smart constructors for validation 274 + 4. **Refined types add validation** - positive, integer, normalized 275 + 5. **Zero runtime cost** - Brand only exists in the type system 276 + 277 + --- 278 + 279 + ## What's Next 280 + 281 + We've covered the foundational types: Result, Option, and Branded. Now it's time to go deeper into the Effect system - how to compose async operations with typed errors and dependencies. 282 + 283 + [Continue to Chapter 7: The Effect System →](./07-the-effect-system.md)
+327
docs/guides/tutorial/07-the-effect-system.md
··· 1 + # Chapter 7: The Effect System 2 + 3 + In Chapter 2, we introduced Effects as "descriptions of work." This chapter goes deeper into the `Eff<A, E, R>` type and how to compose complex async operations. 4 + 5 + --- 6 + 7 + ## Effects Are Data, Not Computation 8 + 9 + This is the key insight. An Effect is a **value** that describes what should happen: 10 + 11 + ```typescript 12 + import { succeed, fail, pipe, flatMap } from "purus-ts" 13 + 14 + // This creates a value - nothing runs yet 15 + const program = pipe( 16 + succeed(10), 17 + flatMap(n => succeed(n * 2)), 18 + flatMap(n => succeed(n.toString())) 19 + ) 20 + 21 + // program is data: { _tag: "FlatMap", effect: {...}, f: ... } 22 + ``` 23 + 24 + Only when you call `runPromise(program)` does anything actually happen. 25 + 26 + This matters because: 27 + - You can inspect, transform, and combine effects before running 28 + - You decide exactly when side effects occur 29 + - Testing is easier - effects are just values to compare 30 + 31 + --- 32 + 33 + ## The Eff<A, E, R> Type 34 + 35 + ```typescript 36 + Eff<A, E, R> 37 + // ^ ^ ^ 38 + // | | └── Requirements (dependencies needed) 39 + // | └───── Error (what can go wrong) 40 + // └──────── Success (what you get if it works) 41 + ``` 42 + 43 + Examples: 44 + 45 + ```typescript 46 + // Succeeds with a number, never fails, no requirements 47 + const eff1: Eff<number, never, unknown> = succeed(42) 48 + 49 + // Always fails with MyError, no requirements 50 + const eff2: Eff<never, MyError, unknown> = fail({ _tag: "MyError" }) 51 + 52 + // Succeeds with User, might fail with DbError, requires Database 53 + const eff3: Eff<User, DbError, Database> = queryUser(id) 54 + ``` 55 + 56 + --- 57 + 58 + ## All the Constructors 59 + 60 + ### succeed() - A Value 61 + 62 + ```typescript 63 + const eff = succeed(42) 64 + // Eff<number, never, unknown> 65 + ``` 66 + 67 + ### fail() - An Error 68 + 69 + ```typescript 70 + type MyError = { _tag: "MyError"; message: string } 71 + const eff = fail<MyError>({ _tag: "MyError", message: "oops" }) 72 + // Eff<never, MyError, unknown> 73 + ``` 74 + 75 + ### sync() - Delayed Synchronous Computation 76 + 77 + ```typescript 78 + const now = sync(() => new Date()) 79 + // Eff<Date, never, unknown> 80 + // Computes a NEW date each time it runs 81 + ``` 82 + 83 + ### attempt() - Catching Exceptions 84 + 85 + ```typescript 86 + const parsed = attempt(() => JSON.parse(userInput)) 87 + // Eff<unknown, unknown, unknown> 88 + // If JSON.parse throws, becomes failure 89 + ``` 90 + 91 + ### fromPromise() - Wrapping Promises 92 + 93 + ```typescript 94 + const fetched = fromPromise(() => fetch("/api").then(r => r.json())) 95 + // Eff<unknown, unknown, unknown> 96 + // The thunk () => ... delays execution 97 + ``` 98 + 99 + ### async() - Full Control 100 + 101 + For callbacks with cleanup: 102 + 103 + ```typescript 104 + const delay = (ms: number): Eff<void, never, unknown> => 105 + async(resume => { 106 + const id = setTimeout(() => resume({ _tag: "Success", value: undefined }), ms) 107 + return () => clearTimeout(id) // Cleanup on cancellation 108 + }) 109 + ``` 110 + 111 + --- 112 + 113 + ## Composing Effects 114 + 115 + ### pipe() - The Composition Pattern 116 + 117 + ```typescript 118 + import { pipe, succeed, flatMap, mapEff } from "purus-ts" 119 + 120 + const result = pipe( 121 + succeed(10), // Start with 10 122 + mapEff(n => n * 2), // Transform to 20 123 + flatMap(n => succeed(n + 5)), // Chain effect returning 25 124 + mapEff(String) // Transform to "25" 125 + ) 126 + ``` 127 + 128 + Read top-to-bottom. Each step transforms or chains the previous. 129 + 130 + ### flatMap() - Sequential Chaining 131 + 132 + The core operator. When one effect depends on another's result: 133 + 134 + ```typescript 135 + const program = pipe( 136 + getUser(userId), 137 + flatMap(user => getOrders(user.id)), 138 + flatMap(orders => processOrders(orders)) 139 + ) 140 + ``` 141 + 142 + If any step fails, subsequent steps are skipped. 143 + 144 + ### mapEff() - Transform Success 145 + 146 + When you just need to transform the value (not run another effect): 147 + 148 + ```typescript 149 + pipe( 150 + succeed({ name: "Alice", age: 30 }), 151 + mapEff(user => user.name.toUpperCase()) 152 + ) 153 + ``` 154 + 155 + ### catchAll() - Handle Errors 156 + 157 + ```typescript 158 + pipe( 159 + fetchUser(id), 160 + catchAll(error => ( 161 + console.log(`Failed: ${error._tag}`), 162 + succeed(defaultUser) 163 + )) 164 + ) 165 + ``` 166 + 167 + ### foldEff() - Handle Both Cases 168 + 169 + ```typescript 170 + pipe( 171 + someEffect, 172 + foldEff( 173 + error => succeed(`Error: ${error._tag}`), 174 + value => succeed(`Success: ${value}`) 175 + ) 176 + ) 177 + ``` 178 + 179 + --- 180 + 181 + ## Building Complex Effects 182 + 183 + Effects compose naturally. Here's a real example: 184 + 185 + ```typescript 186 + import { 187 + type Eff, succeed, fail, pipe, flatMap, mapEff, catchAll 188 + } from "purus-ts" 189 + 190 + type User = { id: string; name: string; email: string } 191 + type Order = { id: string; userId: string; items: string[] } 192 + 193 + type AppError = 194 + | { _tag: "UserNotFound"; id: string } 195 + | { _tag: "NoOrders"; userId: string } 196 + | { _tag: "ProcessingFailed"; reason: string } 197 + 198 + // Simulated operations 199 + const fetchUser = (id: string): Eff<User, AppError, unknown> => 200 + id === "user-1" 201 + ? succeed({ id, name: "Alice", email: "alice@example.com" }) 202 + : fail({ _tag: "UserNotFound", id }) 203 + 204 + const fetchOrders = (userId: string): Eff<Order[], AppError, unknown> => 205 + userId === "user-1" 206 + ? succeed([{ id: "order-1", userId, items: ["item-a", "item-b"] }]) 207 + : fail({ _tag: "NoOrders", userId }) 208 + 209 + const processOrders = (orders: Order[]): Eff<string, AppError, unknown> => 210 + orders.length > 0 211 + ? succeed(`Processed ${orders.length} orders`) 212 + : fail({ _tag: "ProcessingFailed", reason: "No orders to process" }) 213 + 214 + // Compose into a program 215 + const processUserOrders = (userId: string): Eff<string, AppError, unknown> => 216 + pipe( 217 + fetchUser(userId), 218 + flatMap(user => fetchOrders(user.id)), 219 + flatMap(processOrders), 220 + catchAll(error => 221 + succeed(`Handled error: ${error._tag}`) 222 + ) 223 + ) 224 + ``` 225 + 226 + --- 227 + 228 + ## Running Effects 229 + 230 + ### runPromise() - Success or Throw 231 + 232 + ```typescript 233 + const value = await runPromise(effect) 234 + // If effect fails, throws the error 235 + ``` 236 + 237 + ### runPromiseExit() - Get Exit Value 238 + 239 + ```typescript 240 + const exit = await runPromiseExit(effect) 241 + 242 + if (exit._tag === "Success") { 243 + console.log(exit.value) 244 + } else if (exit._tag === "Failure") { 245 + console.log(exit.error) 246 + } else { 247 + console.log("Interrupted") 248 + } 249 + ``` 250 + 251 + ### runPromiseWith() - With Environment 252 + 253 + ```typescript 254 + const exit = await runPromiseWith(effect, myEnvironment) 255 + ``` 256 + 257 + --- 258 + 259 + ## Exercise: API Client 260 + 261 + Build a composable API client: 262 + 263 + ```typescript 264 + import { 265 + type Eff, succeed, fail, pipe, flatMap, mapEff, catchAll, fromPromise 266 + } from "purus-ts" 267 + 268 + type ApiError = 269 + | { _tag: "NetworkError"; message: string } 270 + | { _tag: "NotFound"; url: string } 271 + | { _tag: "ServerError"; status: number } 272 + 273 + const fetchJson = <T>(url: string): Eff<T, ApiError, unknown> => 274 + pipe( 275 + fromPromise(() => fetch(url)), 276 + flatMap(response => 277 + !response.ok 278 + ? response.status === 404 279 + ? fail({ _tag: "NotFound", url }) 280 + : fail({ _tag: "ServerError", status: response.status }) 281 + : fromPromise(() => response.json()) 282 + ), 283 + catchAll(e => 284 + e && typeof e === "object" && "_tag" in e 285 + ? fail(e as ApiError) 286 + : fail({ _tag: "NetworkError", message: String(e) }) 287 + ) 288 + ) as Eff<T, ApiError, unknown> 289 + 290 + // Usage 291 + type User = { id: number; name: string } 292 + type Post = { id: number; title: string; userId: number } 293 + 294 + const getUser = (id: number) => 295 + fetchJson<User>(`https://jsonplaceholder.typicode.com/users/${id}`) 296 + 297 + const getUserPosts = (userId: number) => 298 + fetchJson<Post[]>(`https://jsonplaceholder.typicode.com/users/${userId}/posts`) 299 + 300 + const program = pipe( 301 + getUser(1), 302 + flatMap(user => 303 + pipe( 304 + getUserPosts(user.id), 305 + mapEff(posts => ({ user, posts })) 306 + ) 307 + ) 308 + ) 309 + ``` 310 + 311 + --- 312 + 313 + ## Key Takeaways 314 + 315 + 1. **Effects are data** - They describe work, don't perform it 316 + 2. **Eff<A, E, R>** - Success type, Error type, Requirements 317 + 3. **flatMap chains effects** - Sequential composition 318 + 4. **mapEff transforms values** - Simple transformation 319 + 5. **pipe() reads top-to-bottom** - Clear data flow 320 + 321 + --- 322 + 323 + ## What's Next 324 + 325 + So far, our effects run one at a time. What about running multiple effects in parallel? Racing them? Cancelling long-running work? The next chapter covers concurrency with Fibers. 326 + 327 + [Continue to Chapter 8: Concurrency with Fibers →](./08-concurrency-with-fibers.md)
+319
docs/guides/tutorial/08-concurrency-with-fibers.md
··· 1 + # Chapter 8: Concurrency with Fibers 2 + 3 + Promises give you basic concurrency with `Promise.all` and `Promise.race`. But they have limitations: `Promise.race` doesn't cancel the loser, `Promise.all` doesn't handle partial failures well, and there's no way to interrupt running work. 4 + 5 + This chapter introduces Fibers - purus's lightweight threads with real cancellation. 6 + 7 + --- 8 + 9 + ## The Problem: Promises Don't Cancel 10 + 11 + Consider `Promise.race`: 12 + 13 + ```typescript 14 + const result = await Promise.race([ 15 + fetch("/api/slow"), // Takes 10 seconds 16 + sleep(1000).then(() => "timeout") 17 + ]) 18 + // After 1 second, result is "timeout" 19 + // BUT the slow fetch is STILL RUNNING in the background! 20 + ``` 21 + 22 + The "loser" keeps executing, consuming resources and potentially causing side effects. 23 + 24 + --- 25 + 26 + ## The Solution: Fibers 27 + 28 + A Fiber is a lightweight thread that can be: 29 + - **Awaited** - Get its result 30 + - **Joined** - Convert to an effect 31 + - **Interrupted** - Cancel it 32 + 33 + ```typescript 34 + import { fork, join, interruptFiber, succeed, pipe, flatMap, runPromise } from "purus-ts" 35 + 36 + // Fork an effect to run in the background 37 + const program = pipe( 38 + fork(longRunningEffect), 39 + flatMap(fiber => 40 + pipe( 41 + doOtherWork(), 42 + flatMap(() => join(fiber)) // Wait for the fiber 43 + ) 44 + ) 45 + ) 46 + ``` 47 + 48 + --- 49 + 50 + ## fork() - Run in Background 51 + 52 + `fork()` starts an effect without waiting for it: 53 + 54 + ```typescript 55 + import { fork, succeed, sleep, pipe, flatMap, mapEff, runPromise } from "purus-ts" 56 + 57 + const program = pipe( 58 + fork(pipe( 59 + sleep(1000), 60 + mapEff(() => "Background done!") 61 + )), 62 + flatMap(fiber => { 63 + console.log("Fiber started, doing other work...") 64 + return fiber.join() 65 + }) 66 + ) 67 + 68 + await runPromise(program) 69 + // "Fiber started, doing other work..." 70 + // (1 second later) 71 + // Returns: "Background done!" 72 + ``` 73 + 74 + --- 75 + 76 + ## join() - Wait for a Fiber 77 + 78 + Convert a fiber back to an effect: 79 + 80 + ```typescript 81 + import { join } from "purus-ts" 82 + 83 + const fiber = await runPromise(fork(someEffect)) 84 + 85 + // Later, wait for it 86 + const result = await runPromise(join(fiber)) 87 + ``` 88 + 89 + Or use the fiber's `join()` method: 90 + 91 + ```typescript 92 + const result = await runPromise(fiber.join()) 93 + ``` 94 + 95 + --- 96 + 97 + ## interruptFiber() - Cancel a Fiber 98 + 99 + Stop a running fiber: 100 + 101 + ```typescript 102 + import { fork, interruptFiber, sleep, pipe, flatMap, runPromise } from "purus-ts" 103 + 104 + const program = pipe( 105 + fork(pipe( 106 + sleep(10000), // 10 seconds 107 + mapEff(() => "This won't complete") 108 + )), 109 + flatMap(fiber => 110 + pipe( 111 + sleep(100), // Wait 100ms 112 + flatMap(() => interruptFiber(fiber)), 113 + flatMap(() => succeed("Interrupted!")) 114 + ) 115 + ) 116 + ) 117 + 118 + await runPromise(program) // Returns "Interrupted!" after ~100ms 119 + ``` 120 + 121 + The key: the 10-second sleep is **actually cancelled**, not just ignored. 122 + 123 + --- 124 + 125 + ## race() - First One Wins 126 + 127 + `race()` runs two effects and returns whichever finishes first. The loser is interrupted: 128 + 129 + ```typescript 130 + import { race, succeed, sleep, pipe, mapEff, runPromise } from "purus-ts" 131 + 132 + const slow = pipe(sleep(5000), mapEff(() => "slow")) 133 + const fast = pipe(sleep(100), mapEff(() => "fast")) 134 + 135 + const result = await runPromise(race(slow, fast)) 136 + // Returns "fast" after ~100ms 137 + // The slow effect is CANCELLED, not just ignored 138 + ``` 139 + 140 + ### Implementing Timeout with race() 141 + 142 + ```typescript 143 + import { race, fail, pipe, mapEff } from "purus-ts" 144 + 145 + type TimeoutError = { _tag: "Timeout"; ms: number } 146 + 147 + const withTimeout = <A, E, R>(ms: number, effect: Eff<A, E, R>): Eff<A, E | TimeoutError, R> => 148 + race( 149 + effect, 150 + pipe( 151 + sleep(ms), 152 + flatMap(() => fail({ _tag: "Timeout", ms })) 153 + ) 154 + ) 155 + ``` 156 + 157 + ### Built-in timeout() 158 + 159 + purus includes a `timeout()` combinator: 160 + 161 + ```typescript 162 + import { timeout, pipe, flatMap, fail, succeed, runPromise } from "purus-ts" 163 + 164 + const result = await runPromise( 165 + pipe( 166 + someSlowEffect, 167 + timeout(1000), 168 + flatMap(value => 169 + value === null 170 + ? fail({ _tag: "Timeout" }) 171 + : succeed(value) 172 + ) 173 + ) 174 + ) 175 + ``` 176 + 177 + `timeout()` returns `null` on timeout, letting you handle it as you prefer. 178 + 179 + --- 180 + 181 + ## all() - Parallel Execution 182 + 183 + Run multiple effects in parallel, collect all results: 184 + 185 + ```typescript 186 + import { all, succeed, sleep, pipe, mapEff, runPromise } from "purus-ts" 187 + 188 + const effects = [ 189 + pipe(sleep(100), mapEff(() => "a")), 190 + pipe(sleep(200), mapEff(() => "b")), 191 + pipe(sleep(150), mapEff(() => "c")), 192 + ] 193 + 194 + const results = await runPromise(all(effects)) 195 + // ["a", "b", "c"] after ~200ms (max of all delays) 196 + ``` 197 + 198 + If any effect fails, all others are interrupted and the error is returned. 199 + 200 + ### allSequential() - Sequential Execution 201 + 202 + When you need sequential processing: 203 + 204 + ```typescript 205 + import { allSequential } from "purus-ts" 206 + 207 + const results = await runPromise(allSequential(effects)) 208 + // Same result, but runs one at a time (~450ms total) 209 + ``` 210 + 211 + --- 212 + 213 + ## Cleanup Functions 214 + 215 + The magic of real cancellation comes from cleanup functions: 216 + 217 + ```typescript 218 + import { async, Exit } from "purus-ts" 219 + 220 + const cancellableDelay = (ms: number) => 221 + async<void, never>(resume => { 222 + const id = setTimeout( 223 + () => resume(Exit.succeed(undefined)), 224 + ms 225 + ) 226 + 227 + // This runs on cancellation 228 + return () => { 229 + clearTimeout(id) 230 + console.log("Delay cancelled!") 231 + } 232 + }) 233 + ``` 234 + 235 + When you interrupt a fiber or lose a race, the cleanup function runs. 236 + 237 + ### Real-World Example: HTTP Request 238 + 239 + ```typescript 240 + const fetchWithCancel = (url: string) => 241 + async<Response, Error>(resume => { 242 + const controller = new AbortController() 243 + 244 + fetch(url, { signal: controller.signal }) 245 + .then(response => resume(Exit.succeed(response))) 246 + .catch(error => { 247 + if (error.name !== "AbortError") { 248 + resume(Exit.fail(error)) 249 + } 250 + // Don't resume on AbortError - fiber is already done 251 + }) 252 + 253 + return () => { 254 + controller.abort() // Actually cancel the request! 255 + } 256 + }) 257 + ``` 258 + 259 + --- 260 + 261 + ## Exercise: Parallel Fetch with Timeout 262 + 263 + Build a function that fetches multiple URLs in parallel with an overall timeout: 264 + 265 + ```typescript 266 + import { 267 + type Eff, all, timeout, pipe, flatMap, fail, succeed, mapEff 268 + } from "purus-ts" 269 + 270 + type FetchError = 271 + | { _tag: "Timeout"; ms: number } 272 + | { _tag: "NetworkError"; url: string; message: string } 273 + 274 + const fetchAll = (urls: string[], timeoutMs: number): Eff<string[], FetchError, unknown> => { 275 + const fetches = urls.map(url => 276 + pipe( 277 + fromPromise(() => fetch(url).then(r => r.text())), 278 + catchAll(e => fail({ _tag: "NetworkError", url, message: String(e) })) 279 + ) 280 + ) 281 + 282 + return pipe( 283 + all(fetches), 284 + timeout(timeoutMs), 285 + flatMap(result => 286 + result === null 287 + ? fail({ _tag: "Timeout", ms: timeoutMs }) 288 + : succeed(result) 289 + ) 290 + ) 291 + } 292 + 293 + // Usage 294 + const urls = [ 295 + "https://example.com/a", 296 + "https://example.com/b", 297 + "https://example.com/c", 298 + ] 299 + 300 + const result = await runPromiseExit(fetchAll(urls, 5000)) 301 + ``` 302 + 303 + --- 304 + 305 + ## Key Takeaways 306 + 307 + 1. **Fibers are lightweight threads** - Fork, join, interrupt 308 + 2. **race() actually cancels the loser** - Unlike Promise.race 309 + 3. **all() runs in parallel** - Interrupts all on first failure 310 + 4. **Cleanup functions enable real cancellation** - clearTimeout, abort controllers 311 + 5. **timeout() returns null** - Handle it however you prefer 312 + 313 + --- 314 + 315 + ## What's Next 316 + 317 + We've seen effects that require certain dependencies (the `R` type parameter). The next chapter shows how to use dependency injection to provide these requirements - without any DI framework. 318 + 319 + [Continue to Chapter 9: Dependency Injection →](./09-dependency-injection.md)
+350
docs/guides/tutorial/09-dependency-injection.md
··· 1 + # Chapter 9: Dependency Injection 2 + 3 + Testing is hard when your code has hardcoded dependencies. You end up mocking modules, setting NODE_ENV, or building elaborate test fixtures. 4 + 5 + This chapter shows how purus handles dependency injection through the type system - no framework needed. 6 + 7 + --- 8 + 9 + ## The Problem: Hardcoded Dependencies 10 + 11 + ```typescript 12 + // This is hard to test 13 + const saveUser = async (user: User): Promise<void> => { 14 + await database.insert("users", user) // Hardcoded database 15 + await logger.info(`Saved user ${user.id}`) // Hardcoded logger 16 + await analytics.track("user_saved", user.id) // Hardcoded analytics 17 + } 18 + ``` 19 + 20 + To test this, you'd need to mock three global modules. That's fragile and couples your tests to implementation details. 21 + 22 + --- 23 + 24 + ## The Solution: The R Type Parameter 25 + 26 + Remember `Eff<A, E, R>`? The `R` is for Requirements - dependencies the effect needs. 27 + 28 + ```typescript 29 + import { type Eff, accessEff, succeed } from "purus-ts" 30 + 31 + // Define what we need 32 + type Database = { 33 + insert: (table: string, data: unknown) => Promise<void> 34 + } 35 + 36 + type Logger = { 37 + info: (message: string) => void 38 + } 39 + 40 + type AppEnv = { 41 + database: Database 42 + logger: Logger 43 + } 44 + 45 + // Effect that REQUIRES AppEnv 46 + const saveUser = (user: User): Eff<void, never, AppEnv> => 47 + accessEff((env: AppEnv) => { 48 + env.logger.info(`Saving user ${user.id}`) 49 + return fromPromise(() => env.database.insert("users", user)) 50 + }) 51 + ``` 52 + 53 + The type `Eff<void, never, AppEnv>` says: "This effect produces void, never fails, and **requires** AppEnv to run." 54 + 55 + --- 56 + 57 + ## access() and accessEff() 58 + 59 + ### access() - Read a Value 60 + 61 + ```typescript 62 + import { access } from "purus-ts" 63 + 64 + type Config = { apiUrl: string; timeout: number } 65 + 66 + const getApiUrl = access((config: Config) => config.apiUrl) 67 + // Eff<string, never, Config> 68 + ``` 69 + 70 + ### accessEff() - Read and Run an Effect 71 + 72 + ```typescript 73 + import { accessEff, succeed, pipe, flatMap } from "purus-ts" 74 + 75 + type Logger = { log: (msg: string) => void } 76 + 77 + const log = (message: string): Eff<void, never, Logger> => 78 + accessEff((env: Logger) => ( 79 + env.log(message), 80 + succeed(undefined) 81 + )) 82 + 83 + // Use in a program 84 + const program = pipe( 85 + log("Starting..."), 86 + flatMap(() => doWork()), 87 + flatMap(() => log("Done!")) 88 + ) 89 + ``` 90 + 91 + --- 92 + 93 + ## provide() - Supply Dependencies 94 + 95 + `provide()` gives the effect its requirements: 96 + 97 + ```typescript 98 + import { provide, pipe, runPromise } from "purus-ts" 99 + 100 + // Our effect requires Logger 101 + const program: Eff<string, never, Logger> = pipe( 102 + log("Hello"), 103 + flatMap(() => succeed("result")) 104 + ) 105 + 106 + // Production environment 107 + const prodLogger: Logger = { 108 + log: msg => console.log(`[PROD] ${msg}`) 109 + } 110 + 111 + // Test environment 112 + const testLogger: Logger = { 113 + log: () => {} // Silent in tests 114 + } 115 + 116 + // Provide the environment 117 + const runnable = pipe(program, provide(prodLogger)) 118 + // Eff<string, never, unknown> - requirement satisfied! 119 + 120 + await runPromise(runnable) // "[PROD] Hello" 121 + ``` 122 + 123 + Notice how `provide()` changes the type from `Eff<A, E, Logger>` to `Eff<A, E, unknown>`. The requirement is gone. 124 + 125 + --- 126 + 127 + ## Building Layered Environments 128 + 129 + For larger apps, compose environments: 130 + 131 + ```typescript 132 + // Individual service types 133 + type Database = { 134 + query: <T>(sql: string) => Promise<T[]> 135 + insert: (table: string, data: unknown) => Promise<void> 136 + } 137 + 138 + type Cache = { 139 + get: <T>(key: string) => T | undefined 140 + set: <T>(key: string, value: T) => void 141 + } 142 + 143 + type Logger = { 144 + info: (msg: string) => void 145 + error: (msg: string) => void 146 + } 147 + 148 + // Combined environment 149 + type AppEnv = { 150 + database: Database 151 + cache: Cache 152 + logger: Logger 153 + } 154 + 155 + // Production environment 156 + const prodEnv: AppEnv = { 157 + database: { 158 + query: sql => realDb.query(sql), 159 + insert: (table, data) => realDb.insert(table, data), 160 + }, 161 + cache: { 162 + get: key => redisClient.get(key), 163 + set: (key, value) => redisClient.set(key, value), 164 + }, 165 + logger: { 166 + info: msg => console.log(`[INFO] ${msg}`), 167 + error: msg => console.error(`[ERROR] ${msg}`), 168 + }, 169 + } 170 + 171 + // Test environment 172 + const testEnv: AppEnv = { 173 + database: { 174 + query: async () => [], // Empty results 175 + insert: async () => {}, // No-op 176 + }, 177 + cache: { 178 + get: () => undefined, 179 + set: () => {}, 180 + }, 181 + logger: { 182 + info: () => {}, 183 + error: () => {}, 184 + }, 185 + } 186 + ``` 187 + 188 + --- 189 + 190 + ## Pattern: Service Functions 191 + 192 + Create service functions that access specific parts of the environment: 193 + 194 + ```typescript 195 + // Database service 196 + const query = <T>(sql: string): Eff<T[], never, AppEnv> => 197 + accessEff((env: AppEnv) => 198 + fromPromise(() => env.database.query<T>(sql)) 199 + ) 200 + 201 + const insert = (table: string, data: unknown): Eff<void, never, AppEnv> => 202 + accessEff((env: AppEnv) => 203 + fromPromise(() => env.database.insert(table, data)) 204 + ) 205 + 206 + // Logger service 207 + const logInfo = (msg: string): Eff<void, never, AppEnv> => 208 + accessEff((env: AppEnv) => ( 209 + env.logger.info(msg), 210 + succeed(undefined) 211 + )) 212 + 213 + const logError = (msg: string): Eff<void, never, AppEnv> => 214 + accessEff((env: AppEnv) => ( 215 + env.logger.error(msg), 216 + succeed(undefined) 217 + )) 218 + 219 + // Use in business logic 220 + const createUser = (name: string, email: string): Eff<User, never, AppEnv> => 221 + pipe( 222 + logInfo(`Creating user: ${name}`), 223 + flatMap(() => { 224 + const user = { id: crypto.randomUUID(), name, email } 225 + return pipe( 226 + insert("users", user), 227 + mapEff(() => user) 228 + ) 229 + }), 230 + flatMap(user => 231 + pipe( 232 + logInfo(`Created user: ${user.id}`), 233 + mapEff(() => user) 234 + ) 235 + ) 236 + ) 237 + ``` 238 + 239 + --- 240 + 241 + ## Testing with Different Environments 242 + 243 + ```typescript 244 + import { runPromiseExit, provide, pipe } from "purus-ts" 245 + 246 + describe("createUser", () => { 247 + it("inserts into database", async () => { 248 + const insertedData: unknown[] = [] 249 + 250 + const testEnv: AppEnv = { 251 + database: { 252 + query: async () => [], 253 + insert: async (table, data) => { insertedData.push({ table, data }) }, 254 + }, 255 + cache: { get: () => undefined, set: () => {} }, 256 + logger: { info: () => {}, error: () => {} }, 257 + } 258 + 259 + const result = await runPromiseExit( 260 + pipe( 261 + createUser("Alice", "alice@example.com"), 262 + provide(testEnv) 263 + ) 264 + ) 265 + 266 + expect(result._tag).toBe("Success") 267 + expect(insertedData).toHaveLength(1) 268 + expect(insertedData[0].table).toBe("users") 269 + }) 270 + }) 271 + ``` 272 + 273 + No mocking framework. No module patching. Just pass a different environment. 274 + 275 + --- 276 + 277 + ## Exercise: Configurable API Client 278 + 279 + Build an API client with injectable configuration: 280 + 281 + ```typescript 282 + import { 283 + type Eff, accessEff, fromPromise, pipe, flatMap, mapEff, provide 284 + } from "purus-ts" 285 + 286 + type HttpEnv = { 287 + baseUrl: string 288 + headers: Record<string, string> 289 + timeout: number 290 + } 291 + 292 + const fetchJson = <T>(path: string): Eff<T, unknown, HttpEnv> => 293 + accessEff((env: HttpEnv) => 294 + fromPromise(async () => { 295 + const controller = new AbortController() 296 + const timeoutId = setTimeout(() => controller.abort(), env.timeout) 297 + 298 + const response = await fetch(`${env.baseUrl}${path}`, { 299 + headers: env.headers, 300 + signal: controller.signal, 301 + }) 302 + 303 + clearTimeout(timeoutId) 304 + return response.json() 305 + }) 306 + ) 307 + 308 + // Production config 309 + const prodHttp: HttpEnv = { 310 + baseUrl: "https://api.example.com", 311 + headers: { "Authorization": "Bearer prod-token" }, 312 + timeout: 5000, 313 + } 314 + 315 + // Test config (local mock server) 316 + const testHttp: HttpEnv = { 317 + baseUrl: "http://localhost:3001", 318 + headers: {}, 319 + timeout: 1000, 320 + } 321 + 322 + // Usage 323 + type User = { id: string; name: string } 324 + 325 + const getUser = (id: string) => fetchJson<User>(`/users/${id}`) 326 + 327 + // In production 328 + const prodProgram = pipe(getUser("123"), provide(prodHttp)) 329 + 330 + // In tests 331 + const testProgram = pipe(getUser("123"), provide(testHttp)) 332 + ``` 333 + 334 + --- 335 + 336 + ## Key Takeaways 337 + 338 + 1. **R tracks requirements** - `Eff<A, E, R>` needs R to run 339 + 2. **access() reads from environment** - Simple value extraction 340 + 3. **accessEff() reads and runs effect** - For async dependencies 341 + 4. **provide() supplies dependencies** - Satisfies the R requirement 342 + 5. **No DI framework needed** - Just functions and types 343 + 344 + --- 345 + 346 + ## What's Next 347 + 348 + You've learned all the core concepts! The final chapter puts everything together in a complete application. 349 + 350 + [Continue to Chapter 10: Building a Complete App →](./10-building-a-complete-app.md)
+476
docs/guides/tutorial/10-building-a-complete-app.md
··· 1 + # Chapter 10: Building a Complete App 2 + 3 + Time to put everything together. We'll build a CLI task manager that uses every concept from this tutorial: 4 + 5 + - **Branded types** for TaskId 6 + - **Result** for validation 7 + - **Option** for lookups 8 + - **Pattern matching** for commands 9 + - **Effects** for async operations 10 + - **Dependency injection** for persistence 11 + 12 + --- 13 + 14 + ## The Application 15 + 16 + A simple task manager with these commands: 17 + - `add <title>` - Create a task 18 + - `done <id>` - Mark task complete 19 + - `list` - Show all tasks 20 + - `remove <id>` - Delete a task 21 + 22 + --- 23 + 24 + ## Step 1: Define Types 25 + 26 + ```typescript 27 + import { 28 + type Branded, type Option, type Result, type Eff, 29 + brand, some, none, ok, err, succeed, fail, pipe, flatMap, mapEff, 30 + match, matchOption, matchResult, accessEff, provide 31 + } from "purus-ts" 32 + 33 + // Branded TaskId 34 + type TaskId = Branded<string, "TaskId"> 35 + const TaskId = (s: string): TaskId => brand(s) 36 + 37 + // Task status using discriminated union 38 + type TaskStatus = 39 + | { _tag: "Pending" } 40 + | { _tag: "Done"; completedAt: Date } 41 + 42 + // Task entity 43 + type Task = { 44 + id: TaskId 45 + title: string 46 + status: TaskStatus 47 + createdAt: Date 48 + } 49 + 50 + // Commands 51 + type Command = 52 + | { _tag: "Add"; title: string } 53 + | { _tag: "Done"; id: TaskId } 54 + | { _tag: "List" } 55 + | { _tag: "Remove"; id: TaskId } 56 + | { _tag: "Unknown"; input: string } 57 + 58 + // Errors 59 + type AppError = 60 + | { _tag: "TaskNotFound"; id: TaskId } 61 + | { _tag: "InvalidCommand"; input: string } 62 + | { _tag: "EmptyTitle" } 63 + | { _tag: "StorageError"; message: string } 64 + ``` 65 + 66 + --- 67 + 68 + ## Step 2: Environment Types 69 + 70 + ```typescript 71 + // Storage interface 72 + type Storage = { 73 + load: () => Promise<Task[]> 74 + save: (tasks: Task[]) => Promise<void> 75 + } 76 + 77 + // Logger interface 78 + type Logger = { 79 + info: (msg: string) => void 80 + error: (msg: string) => void 81 + } 82 + 83 + // Combined environment 84 + type AppEnv = { 85 + storage: Storage 86 + logger: Logger 87 + } 88 + ``` 89 + 90 + --- 91 + 92 + ## Step 3: Storage Service 93 + 94 + ```typescript 95 + // Load all tasks 96 + const loadTasks = (): Eff<Task[], AppError, AppEnv> => 97 + accessEff((env: AppEnv) => 98 + pipe( 99 + fromPromise(() => env.storage.load()), 100 + catchAll(e => 101 + fail({ _tag: "StorageError", message: String(e) }) 102 + ) 103 + ) 104 + ) 105 + 106 + // Save all tasks 107 + const saveTasks = (tasks: Task[]): Eff<void, AppError, AppEnv> => 108 + accessEff((env: AppEnv) => 109 + pipe( 110 + fromPromise(() => env.storage.save(tasks)), 111 + catchAll(e => 112 + fail({ _tag: "StorageError", message: String(e) }) 113 + ) 114 + ) 115 + ) 116 + 117 + // Find task by ID 118 + const findTask = (tasks: Task[], id: TaskId): Option<Task> => { 119 + const task = tasks.find(t => t.id === id) 120 + return task ? some(task) : none 121 + } 122 + 123 + // Update task in list 124 + const updateTask = (tasks: Task[], id: TaskId, update: (t: Task) => Task): Task[] => 125 + tasks.map(t => t.id === id ? update(t) : t) 126 + ``` 127 + 128 + --- 129 + 130 + ## Step 4: Command Parser 131 + 132 + ```typescript 133 + const parseCommand = (input: string): Command => { 134 + const parts = input.trim().split(/\s+/) 135 + const cmd = parts[0]?.toLowerCase() 136 + const arg = parts.slice(1).join(" ") 137 + 138 + return cmd === "add" && arg 139 + ? { _tag: "Add", title: arg } 140 + : cmd === "done" && arg 141 + ? { _tag: "Done", id: TaskId(arg) } 142 + : cmd === "list" 143 + ? { _tag: "List" } 144 + : cmd === "remove" && arg 145 + ? { _tag: "Remove", id: TaskId(arg) } 146 + : { _tag: "Unknown", input } 147 + } 148 + ``` 149 + 150 + --- 151 + 152 + ## Step 5: Command Handlers 153 + 154 + ```typescript 155 + // Generate unique ID 156 + const generateId = (): TaskId => 157 + TaskId(Math.random().toString(36).substring(2, 9)) 158 + 159 + // Add a new task 160 + const addTask = (title: string): Eff<Task, AppError, AppEnv> => 161 + title.trim().length === 0 162 + ? fail({ _tag: "EmptyTitle" }) 163 + : pipe( 164 + loadTasks(), 165 + flatMap(tasks => { 166 + const task: Task = { 167 + id: generateId(), 168 + title: title.trim(), 169 + status: { _tag: "Pending" }, 170 + createdAt: new Date(), 171 + } 172 + return pipe( 173 + saveTasks([...tasks, task]), 174 + mapEff(() => task) 175 + ) 176 + }) 177 + ) 178 + 179 + // Mark task as done 180 + const completeTask = (id: TaskId): Eff<Task, AppError, AppEnv> => 181 + pipe( 182 + loadTasks(), 183 + flatMap(tasks => 184 + matchOption( 185 + (task: Task) => { 186 + const updated = updateTask(tasks, id, t => ({ 187 + ...t, 188 + status: { _tag: "Done", completedAt: new Date() } as TaskStatus, 189 + })) 190 + return pipe( 191 + saveTasks(updated), 192 + mapEff(() => updated.find(t => t.id === id)!) 193 + ) 194 + }, 195 + () => fail<AppError>({ _tag: "TaskNotFound", id }) 196 + )(findTask(tasks, id)) 197 + ) 198 + ) 199 + 200 + // List all tasks 201 + const listTasks = (): Eff<Task[], AppError, AppEnv> => 202 + loadTasks() 203 + 204 + // Remove a task 205 + const removeTask = (id: TaskId): Eff<void, AppError, AppEnv> => 206 + pipe( 207 + loadTasks(), 208 + flatMap(tasks => 209 + matchOption( 210 + (_task: Task) => 211 + saveTasks(tasks.filter(t => t.id !== id)), 212 + () => fail<AppError>({ _tag: "TaskNotFound", id }) 213 + )(findTask(tasks, id)) 214 + ) 215 + ) 216 + ``` 217 + 218 + --- 219 + 220 + ## Step 6: Logging Service 221 + 222 + ```typescript 223 + const log = (msg: string): Eff<void, never, AppEnv> => 224 + accessEff((env: AppEnv) => ( 225 + env.logger.info(msg), 226 + succeed(undefined) 227 + )) 228 + 229 + const logError = (msg: string): Eff<void, never, AppEnv> => 230 + accessEff((env: AppEnv) => ( 231 + env.logger.error(msg), 232 + succeed(undefined) 233 + )) 234 + ``` 235 + 236 + --- 237 + 238 + ## Step 7: Execute Command 239 + 240 + ```typescript 241 + const formatTask = (task: Task): string => 242 + match(task.status)({ 243 + Pending: () => `[ ] ${task.id}: ${task.title}`, 244 + Done: ({ completedAt }) => 245 + `[x] ${task.id}: ${task.title} (done ${completedAt.toLocaleDateString()})`, 246 + }) 247 + 248 + const formatError = (error: AppError): string => 249 + match(error)({ 250 + TaskNotFound: ({ id }) => `Task not found: ${id}`, 251 + InvalidCommand: ({ input }) => `Invalid command: ${input}`, 252 + EmptyTitle: () => "Task title cannot be empty", 253 + StorageError: ({ message }) => `Storage error: ${message}`, 254 + }) 255 + 256 + const executeCommand = (command: Command): Eff<string, never, AppEnv> => 257 + match(command)({ 258 + Add: ({ title }) => 259 + pipe( 260 + addTask(title), 261 + foldEff( 262 + error => succeed(`Error: ${formatError(error)}`), 263 + task => succeed(`Created: ${formatTask(task)}`) 264 + ) 265 + ), 266 + 267 + Done: ({ id }) => 268 + pipe( 269 + completeTask(id), 270 + foldEff( 271 + error => succeed(`Error: ${formatError(error)}`), 272 + task => succeed(`Completed: ${formatTask(task)}`) 273 + ) 274 + ), 275 + 276 + List: () => 277 + pipe( 278 + listTasks(), 279 + foldEff( 280 + error => succeed(`Error: ${formatError(error)}`), 281 + tasks => 282 + tasks.length === 0 283 + ? succeed("No tasks") 284 + : succeed(tasks.map(formatTask).join("\n")) 285 + ) 286 + ), 287 + 288 + Remove: ({ id }) => 289 + pipe( 290 + removeTask(id), 291 + foldEff( 292 + error => succeed(`Error: ${formatError(error)}`), 293 + () => succeed(`Removed: ${id}`) 294 + ) 295 + ), 296 + 297 + Unknown: ({ input }) => 298 + succeed(`Unknown command: ${input}\nTry: add, done, list, remove`), 299 + }) 300 + ``` 301 + 302 + --- 303 + 304 + ## Step 8: Main Loop 305 + 306 + ```typescript 307 + import * as readline from "readline" 308 + 309 + const createReadline = () => 310 + readline.createInterface({ 311 + input: process.stdin, 312 + output: process.stdout, 313 + }) 314 + 315 + const prompt = (rl: readline.Interface): Eff<string, never, unknown> => 316 + async(resume => { 317 + rl.question("> ", answer => resume(Exit.succeed(answer))) 318 + return () => {} 319 + }) 320 + 321 + const main = (): Eff<void, never, AppEnv> => { 322 + const rl = createReadline() 323 + 324 + const loop = (): Eff<void, never, AppEnv> => 325 + pipe( 326 + prompt(rl), 327 + flatMap(input => { 328 + if (input.toLowerCase() === "quit") { 329 + rl.close() 330 + return succeed(undefined) 331 + } 332 + 333 + const command = parseCommand(input) 334 + return pipe( 335 + executeCommand(command), 336 + flatMap(output => log(output)), 337 + flatMap(() => loop()) 338 + ) 339 + }) 340 + ) 341 + 342 + return pipe( 343 + log("Task Manager - type 'quit' to exit"), 344 + flatMap(() => loop()) 345 + ) 346 + } 347 + ``` 348 + 349 + --- 350 + 351 + ## Step 9: Production Environment 352 + 353 + ```typescript 354 + import * as fs from "fs/promises" 355 + 356 + const DATA_FILE = "./tasks.json" 357 + 358 + const prodStorage: Storage = { 359 + load: async () => { 360 + try { 361 + const data = await fs.readFile(DATA_FILE, "utf-8") 362 + return JSON.parse(data) 363 + } catch { 364 + return [] 365 + } 366 + }, 367 + save: async (tasks) => { 368 + await fs.writeFile(DATA_FILE, JSON.stringify(tasks, null, 2)) 369 + }, 370 + } 371 + 372 + const prodLogger: Logger = { 373 + info: msg => console.log(msg), 374 + error: msg => console.error(`[ERROR] ${msg}`), 375 + } 376 + 377 + const prodEnv: AppEnv = { 378 + storage: prodStorage, 379 + logger: prodLogger, 380 + } 381 + 382 + // Run the app 383 + const app = pipe(main(), provide(prodEnv)) 384 + runPromise(app) 385 + ``` 386 + 387 + --- 388 + 389 + ## Step 10: Test Environment 390 + 391 + ```typescript 392 + const createTestEnv = (): { env: AppEnv; tasks: Task[] } => { 393 + const tasks: Task[] = [] 394 + 395 + return { 396 + tasks, 397 + env: { 398 + storage: { 399 + load: async () => tasks, 400 + save: async (newTasks) => { 401 + tasks.length = 0 402 + tasks.push(...newTasks) 403 + }, 404 + }, 405 + logger: { 406 + info: () => {}, 407 + error: () => {}, 408 + }, 409 + }, 410 + } 411 + } 412 + 413 + // Example test 414 + describe("addTask", () => { 415 + it("creates a task with pending status", async () => { 416 + const { env, tasks } = createTestEnv() 417 + 418 + const result = await runPromiseExit( 419 + pipe(addTask("Learn purus"), provide(env)) 420 + ) 421 + 422 + expect(result._tag).toBe("Success") 423 + expect(tasks).toHaveLength(1) 424 + expect(tasks[0].title).toBe("Learn purus") 425 + expect(tasks[0].status._tag).toBe("Pending") 426 + }) 427 + }) 428 + ``` 429 + 430 + --- 431 + 432 + ## What You've Built 433 + 434 + This small app demonstrates: 435 + 436 + | Concept | Where Used | 437 + |---------|------------| 438 + | Branded types | `TaskId` - distinct from plain strings | 439 + | Result | Validation and error handling | 440 + | Option | `findTask` - task may not exist | 441 + | Pattern matching | `parseCommand`, `formatTask`, `executeCommand` | 442 + | Effects | All async operations | 443 + | Dependency injection | `Storage`, `Logger` via `AppEnv` | 444 + | Composition | `pipe()` throughout | 445 + 446 + --- 447 + 448 + ## Key Takeaways 449 + 450 + 1. **Types document intent** - `TaskId` vs `string`, `Eff<A, E, R>` tells you everything 451 + 2. **Errors are values** - No try/catch, just compose Results and Effects 452 + 3. **Pattern matching is exhaustive** - Add a new command, compiler guides you 453 + 4. **DI is just functions** - No framework, just pass different environments 454 + 5. **Everything composes** - Small functions, big programs 455 + 456 + --- 457 + 458 + ## Congratulations! 459 + 460 + You've completed the purus-ts tutorial! You now understand: 461 + 462 + - Why errors as values beats exceptions 463 + - How to use Result, Option, and Effects 464 + - Pattern matching for exhaustive handling 465 + - Branded types for compile-time safety 466 + - Fiber-based concurrency with real cancellation 467 + - Dependency injection without frameworks 468 + 469 + ## Next Steps 470 + 471 + 1. **Explore the examples** - See `examples/` for real-world patterns 472 + 2. **Read the concept guides** - Deep-dives in `docs/guides/concepts/` 473 + 3. **Build something** - The best way to learn is to use it 474 + 4. **Read the source** - purus is ~950 lines of well-commented TypeScript 475 + 476 + Happy coding!