An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit with library source and examples

+5651
+34
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+15
README.md
··· 1 + # purus-ts 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+26
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "purus-ts", 7 + "devDependencies": { 8 + "@types/bun": "latest", 9 + }, 10 + "peerDependencies": { 11 + "typescript": "^5", 12 + }, 13 + }, 14 + }, 15 + "packages": { 16 + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 17 + 18 + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], 19 + 20 + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 21 + 22 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 23 + 24 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 25 + } 26 + }
+194
examples/README.md
··· 1 + # purus-ts Examples 2 + 3 + Real-world examples demonstrating how purus-ts makes impossible states unrepresentable and complex async workflows simple. 4 + 5 + ## Running Examples 6 + 7 + ```bash 8 + # Run any example 9 + bun run examples/http-client.ts 10 + bun run examples/workflow-engine.ts 11 + bun run examples/reactive-dom.ts 12 + bun run examples/parallel-scraper.ts 13 + bun run examples/task-queue.ts 14 + bun run examples/game-loop.ts 15 + ``` 16 + 17 + --- 18 + 19 + ## Examples Overview 20 + 21 + ### 1. [http-client.ts](./http-client.ts) - Resilient HTTP Client 22 + 23 + **Showcases:** Effect composition, retry, timeout, race, typed errors, dependency injection 24 + 25 + | Problem | Vanilla JS/TS | purus-ts | 26 + |---------|---------------|----------| 27 + | Error types | `catch (e: unknown)` | `Eff<Response, HttpError, Env>` | 28 + | Cancellation | Manual AbortController | Automatic on fiber interrupt | 29 + | Retry logic | Scattered try/catch | `pipe(request, retry(3))` | 30 + | Timeout | Promise.race + cleanup | `timeout(5000)` | 31 + | Fastest server | Race doesn't cancel losers | `race()` cancels losers | 32 + 33 + ```typescript 34 + // Compose timeout, retry, and error handling in one line 35 + const request = pipe( 36 + fetchWithAbort(url), 37 + timeout(5000), 38 + retry(3), 39 + catchAll(handleError) 40 + ) 41 + ``` 42 + 43 + --- 44 + 45 + ### 2. [workflow-engine.ts](./workflow-engine.ts) - Order Processing Pipeline 46 + 47 + **Showcases:** Branded types, refinements, typestate, Result, pattern matching 48 + 49 + | Problem | Vanilla JS/TS | purus-ts | 50 + |---------|---------------|----------| 51 + | ID mixups | `processOrder(orderId, userId)` - args swappable | `processOrder(orderId: OrderId, userId: UserId)` - compiler catches | 52 + | Invalid quantities | `if (qty > 0)` checks everywhere | `PositiveInt` type - validated at construction | 53 + | State transitions | `if (order.status === 'paid')` | `ship(order: PaidOrder)` - can't ship unpaid | 54 + | Error handling | Switch with forgotten cases | `match()` forces exhaustive handling | 55 + 56 + ```typescript 57 + // Can't ship an unpaid order - compile error! 58 + ship(draftOrder) // Type 'DraftOrder' not assignable to 'PaidOrder' 59 + 60 + // All error cases must be handled 61 + match(error)({ 62 + OutOfStock: ..., // Required 63 + PaymentDeclined: ..., // Required 64 + InvalidAddress: ..., // Required - forget one = compile error 65 + }) 66 + ``` 67 + 68 + --- 69 + 70 + ### 3. [reactive-dom.ts](./reactive-dom.ts) - Reactive DOM with Cancellation 71 + 72 + **Showcases:** Fiber cancellation, async effects, resource cleanup, typestate 73 + 74 + | Problem | Vanilla JS/TS | purus-ts | 75 + |---------|---------------|----------| 76 + | Event listeners | Manual add/remove tracking | Cleanup function auto-called on interrupt | 77 + | Memory leaks | Easy to forget cleanup | Impossible - cleanup is part of effect | 78 + | Form validation | `if (form.isValid) submit()` | `submit(form: ValidForm)` - compile-time | 79 + | Debounce | Wrapper functions, manual timing | `pipe(input, debounce(300))` | 80 + 81 + ```typescript 82 + // Event listener cleanup is automatic 83 + const fromEvent = (target, type) => 84 + async((resume) => { 85 + const handler = (e) => resume(Exit.succeed(e)) 86 + target.addEventListener(type, handler) 87 + return () => target.removeEventListener(type, handler) // Auto-called! 88 + }) 89 + 90 + // Race submit vs cancel - loser is interrupted 91 + race(onSubmit, onCancel) 92 + ``` 93 + 94 + --- 95 + 96 + ### 4. [parallel-scraper.ts](./parallel-scraper.ts) - Concurrent Web Scraper 97 + 98 + **Showcases:** Fork, all, race, timeout, retry, fiber supervision, graceful shutdown 99 + 100 + | Problem | Vanilla JS/TS | purus-ts | 101 + |---------|---------------|----------| 102 + | Cancellation | Manual AbortController per request | Fiber interrupt propagates | 103 + | Concurrency limit | Complex semaphore implementation | Fork workers, coordinate with join | 104 + | Progress tracking | Callbacks, events | `fiber.status()` | 105 + | Shutdown | Track all promises, abort each | Interrupt parent → all children stop | 106 + 107 + ```typescript 108 + // Race mirrors - first success wins, others cancelled 109 + const fastest = race( 110 + fetch(mirror1), 111 + fetch(mirror2), 112 + fetch(mirror3) 113 + ) 114 + 115 + // Graceful shutdown - one line 116 + pool.shutdown() // All workers interrupted, cleanup runs 117 + ``` 118 + 119 + --- 120 + 121 + ### 5. [task-queue.ts](./task-queue.ts) - Background Job Queue 122 + 123 + **Showcases:** Fork/join, fiber supervision, retry, timeout, dependency injection 124 + 125 + | Problem | Vanilla JS/TS | purus-ts | 126 + |---------|---------------|----------| 127 + | Worker management | Manual thread/process coordination | Workers are fibers | 128 + | Job retry | Per-job try/catch, counter management | `pipe(job, retry(3))` | 129 + | Timeout per job | Manual setTimeout + cleanup | `timeout(30000)` | 130 + | Dead letter queue | Separate error handling | `catchAll(e => pushToDLQ(job, e))` | 131 + 132 + ```typescript 133 + // Type-safe job handling - add new job type = compiler shows where to handle 134 + const process = match(job)({ 135 + SendEmail: ..., // Required 136 + ProcessImage: ..., // Required 137 + GenerateReport: ..., // Required 138 + SyncData: ..., // Required 139 + Cleanup: ..., // Required - new job type = add here 140 + }) 141 + ``` 142 + 143 + --- 144 + 145 + ### 6. [game-loop.ts](./game-loop.ts) - Game Loop with Typed State 146 + 147 + **Showcases:** Typestate, units, tracked arrays, effect scheduling 148 + 149 + | Problem | Vanilla JS/TS | purus-ts | 150 + |---------|---------------|----------| 151 + | Dead entity attacks | `if (entity.state !== 'dead')` | `attack(entity: AliveEntity)` - compile error for dead | 152 + | Unit mixing | `position += velocity` - wrong! | `Position<Pixels> + Velocity<PixelsPerSecond>` - type error | 153 + | Unsorted render | Hope you sorted by z-index | `Arr<Entity, Sorted>` - guaranteed | 154 + | Frame timing | Manual requestAnimationFrame | `pipe(update, render, sleep(16), loop)` | 155 + 156 + ```typescript 157 + // Can't attack from Dead state - compile error! 158 + attack(deadEntity) // Type 'DeadEntity' not assignable to 'IdleEntity | MovingEntity' 159 + 160 + // Can't mix position and velocity - compile error! 161 + addPixels(position, velocity) // Type 'PixelsPerSecond' not assignable to 'Pixels' 162 + ``` 163 + 164 + --- 165 + 166 + ## Key Takeaways 167 + 168 + ### Why purus-ts over vanilla TypeScript? 169 + 170 + 1. **Impossible states are unrepresentable** 171 + - Branded types prevent ID mixups 172 + - Refinements guarantee validated values 173 + - Typestate prevents invalid transitions 174 + - Pattern matching forces exhaustive handling 175 + 176 + 2. **Effects are data, not computation** 177 + - Can inspect, compose, transform effects before running 178 + - Cancellation propagates automatically 179 + - Stack-safe by construction 180 + 181 + 3. **Concurrency is first-class** 182 + - `fork`, `join`, `race`, `all` - composable primitives 183 + - Automatic cleanup on interrupt 184 + - No callback hell 185 + 186 + 4. **Dependency injection without frameworks** 187 + - `access<Env>()` reads environment 188 + - `provide(env)(effect)` injects dependencies 189 + - Pure functions, no decorators or reflection 190 + 191 + 5. **Composition over configuration** 192 + - `pipe(effect, timeout(5000), retry(3))` - one line 193 + - Each combinator is a pure function 194 + - Build complex workflows from simple parts
+549
examples/demo.ts
··· 1 + /** 2 + * purus — Full Demo 3 + * 4 + * Showcasing all features of the unified library: 5 + * • Part I: Foundations (types, refinements, pattern matching) 6 + * • Part II: Effect System (fibers, concurrency, cancellation) 7 + */ 8 + 9 + import { 10 + // === FOUNDATIONS === 11 + // Brands & Refinements 12 + Branded, 13 + brand, 14 + Refined, 15 + refine, 16 + positive, 17 + integer, 18 + 19 + // Result & Option 20 + Result, 21 + Option, 22 + ok, 23 + err, 24 + some, 25 + none, 26 + mapResult, 27 + chainResult, 28 + unwrapOr, 29 + mapOption, 30 + getOrElse, 31 + flatMapOption, 32 + fromNullable, 33 + 34 + // Pattern Matching 35 + match, 36 + matchResult, 37 + matchOption, 38 + when, 39 + matchLiteral, 40 + 41 + // Arrays 42 + Arr, 43 + arr, 44 + sortNum, 45 + sortBy, 46 + nonEmpty, 47 + map, 48 + filter, 49 + reduce, 50 + head, 51 + last, 52 + binarySearch, 53 + 54 + // Units 55 + Quantity, 56 + meters, 57 + seconds, 58 + velocity, 59 + addQ, 60 + 61 + // State 62 + Entity, 63 + entity, 64 + transition, 65 + 66 + // Composition & Utils 67 + pipe, 68 + flow, 69 + tap, 70 + trace, 71 + ifElse, 72 + 73 + // === EFFECT SYSTEM === 74 + // Core types 75 + Eff, 76 + Fiber, 77 + Exit, 78 + 79 + // Constructors 80 + succeed, 81 + fail, 82 + sync, 83 + async, 84 + fromPromise, 85 + 86 + // Transformations 87 + mapEff, 88 + flatMap, 89 + foldEff, 90 + catchAll, 91 + access, 92 + accessEff, 93 + provide, 94 + 95 + // Concurrency 96 + fork, 97 + join, 98 + race, 99 + all, 100 + 101 + // Combinators 102 + sleep, 103 + timeout, 104 + retry, 105 + repeatEff, 106 + tapEff, 107 + 108 + // Control 109 + yieldNow, 110 + fiberId, 111 + 112 + // Runners 113 + runPromise, 114 + runPromiseExit, 115 + runFiber, 116 + } from "./src/index"; 117 + 118 + // ============================================================================= 119 + // PART I: FOUNDATIONS DEMO 120 + // ============================================================================= 121 + 122 + const foundationsDemo = () => { 123 + console.log( 124 + "╔═══════════════════════════════════════════════════════════════╗", 125 + ); 126 + console.log( 127 + "║ PART I: FOUNDATIONS ║", 128 + ); 129 + console.log( 130 + "╚═══════════════════════════════════════════════════════════════╝", 131 + ); 132 + 133 + // ─── Branded Types ─── 134 + console.log("\n─── Branded Types ───"); 135 + type UserId = Branded<string, "UserId">; 136 + type Email = Branded<string, "Email">; 137 + 138 + const userId: UserId = brand("user-123"); 139 + const email: Email = brand("alice@example.com"); 140 + console.log(`UserId: ${userId}, Email: ${email}`); 141 + // Can't mix them up: userId = email would be a type error! 142 + // 143 + 144 + // ─── Refined Types ─── 145 + console.log("\n─── Refined Types ───"); 146 + const age = positive(25); 147 + const invalid = positive(-5); 148 + console.log(`positive(25) = ${age}`); 149 + console.log(`positive(-5) = ${invalid}`); 150 + 151 + // ─── Result Type ─── 152 + console.log("\n─── Result Type ───"); 153 + const divide = (a: number, b: number): Result<number, "DivByZero"> => 154 + ifElse<number, "DivByZero">(b === 0)( 155 + () => err("DivByZero"), 156 + () => ok(a / b), 157 + ); 158 + 159 + const result1 = pipe( 160 + divide(10, 2), 161 + chainResult((x) => divide(x, 2)), 162 + matchResult( 163 + (v) => `Success: ${v}`, 164 + (e) => `Error: ${e}`, 165 + ), 166 + ); 167 + console.log(`10 / 2 / 2 = ${result1}`); 168 + 169 + const result2 = pipe( 170 + divide(10, 0), 171 + matchResult( 172 + (v) => `Success: ${v}`, 173 + (e) => `Error: ${e}`, 174 + ), 175 + ); 176 + console.log(`10 / 0 = ${result2}`); 177 + 178 + // ─── Option Type ─── 179 + console.log("\n─── Option Type ───"); 180 + const config = { host: "localhost", port: null as number | null }; 181 + const port = pipe(fromNullable(config.port), getOrElse(3000)); 182 + console.log(`Port (with default): ${port}`); 183 + 184 + // ─── Pattern Matching ─── 185 + console.log("\n─── Pattern Matching ───"); 186 + const grade = (score: number) => 187 + when(score)( 188 + [(s) => s >= 90, () => "A"], 189 + [(s) => s >= 80, () => "B"], 190 + [(s) => s >= 70, () => "C"], 191 + )(() => "F"); 192 + 193 + console.log(`Score 85 = Grade ${grade(85)}`); 194 + console.log(`Score 65 = Grade ${grade(65)}`); 195 + 196 + // ─── Tracked Arrays ─── 197 + console.log("\n─── Tracked Arrays ───"); 198 + type User = { name: string; score: number }; 199 + const users: User[] = [ 200 + { name: "Charlie", score: 75 }, 201 + { name: "Alice", score: 92 }, 202 + { name: "Bob", score: 88 }, 203 + ]; 204 + 205 + const topScorer = pipe( 206 + arr(users), 207 + sortBy((u) => -u.score), // Arr<User, Sorted> 208 + nonEmpty, // Option<Arr<User, Sorted | NonEmpty>> 209 + matchOption( 210 + (xs) => `Top: ${head(xs).name} (${head(xs).score})`, 211 + () => "No users", 212 + ), 213 + ); 214 + console.log(topScorer); 215 + 216 + // ─── Units ─── 217 + console.log("\n─── Units ───"); 218 + const distance = meters(100); 219 + const time = seconds(9.58); 220 + const speed = velocity(distance, time); 221 + console.log(`${distance}m in ${time}s = ${speed.toFixed(2)} m/s`); 222 + 223 + // addQ(distance, time) would be a type error! 224 + const totalDistance = addQ(distance, meters(200)); 225 + console.log(`Total distance: ${totalDistance}m`); 226 + 227 + // ─── State Machines ─── 228 + console.log("\n─── State Machines ───"); 229 + type Doc = { title: string; content: string }; 230 + type Draft = Entity<Doc, "draft">; 231 + type Published = Entity<Doc, "published">; 232 + 233 + const createDraft = (title: string): Draft => 234 + entity<Doc, "draft">({ title, content: "" }); 235 + 236 + const edit = (content: string) => 237 + transition<Doc, "draft", "draft">((d) => ({ ...d, content })); 238 + 239 + const publish: (d: Draft) => Published = transition< 240 + Doc, 241 + "draft", 242 + "published" 243 + >(); 244 + 245 + const doc = pipe(createDraft("My Post"), edit("Hello, world!"), publish); 246 + console.log(`Published: "${doc.title}" - "${doc.content}"`); 247 + }; 248 + 249 + // ============================================================================= 250 + // PART II: EFFECT SYSTEM DEMO 251 + // ============================================================================= 252 + 253 + const effectDemo = async () => { 254 + console.log( 255 + "\n╔═══════════════════════════════════════════════════════════════╗", 256 + ); 257 + console.log( 258 + "║ PART II: EFFECT SYSTEM ║", 259 + ); 260 + console.log( 261 + "╚═══════════════════════════════════════════════════════════════╝", 262 + ); 263 + 264 + // ─── Basic Effects ─── 265 + console.log("\n─── Basic Effects ───"); 266 + 267 + const greeting = pipe( 268 + succeed("Hello"), 269 + flatMap((s) => succeed(`${s}, World!`)), 270 + ); 271 + console.log(await runPromise(greeting)); 272 + 273 + // ─── Error Handling ─── 274 + console.log("\n─── Error Handling ───"); 275 + 276 + type ApiError = { _tag: "ApiError"; code: number }; 277 + 278 + const riskyCall = fail<ApiError>({ _tag: "ApiError", code: 404 }); 279 + 280 + const handled = pipe( 281 + riskyCall, 282 + catchAll((e) => succeed(`Caught error: ${e.code}`)), 283 + ); 284 + console.log(await runPromise(handled)); 285 + 286 + // ─── Async Effects ─── 287 + console.log("\n─── Async Effects ───"); 288 + 289 + const delayed = pipe( 290 + sync(() => console.log(" Starting...")), 291 + flatMap(() => sleep(100)), 292 + flatMap(() => succeed("Done after 100ms")), 293 + ); 294 + console.log(await runPromise(delayed)); 295 + 296 + // ─── Timeout ─── 297 + console.log("\n─── Timeout ───"); 298 + 299 + const slow = pipe( 300 + sleep(500), 301 + mapEff(() => "completed"), 302 + ); 303 + const fast = pipe( 304 + sleep(50), 305 + mapEff(() => "completed"), 306 + ); 307 + 308 + console.log("Slow with 100ms timeout:", await runPromise(timeout(100)(slow))); 309 + console.log("Fast with 100ms timeout:", await runPromise(timeout(100)(fast))); 310 + 311 + // ─── Parallel Execution ─── 312 + console.log("\n─── Parallel Execution ───"); 313 + 314 + const task = (name: string, ms: number) => 315 + pipe( 316 + sync(() => console.log(` ${name} starting`)), 317 + flatMap(() => sleep(ms)), 318 + flatMap(() => 319 + sync(() => { 320 + console.log(` ${name} done`); 321 + return name; 322 + }), 323 + ), 324 + ); 325 + 326 + const start = Date.now(); 327 + const results = await runPromise( 328 + all([task("A", 100), task("B", 150), task("C", 50)]), 329 + ); 330 + console.log(`Results: [${results.join(", ")}] in ${Date.now() - start}ms`); 331 + 332 + // ─── Racing ─── 333 + console.log("\n─── Racing ───"); 334 + 335 + const winner = await runPromise( 336 + race( 337 + pipe( 338 + sleep(100), 339 + mapEff(() => "Slow"), 340 + ), 341 + pipe( 342 + sleep(30), 343 + mapEff(() => "Fast"), 344 + ), 345 + ), 346 + ); 347 + console.log(`Winner: ${winner}`); 348 + 349 + // ─── Cancellation ─── 350 + console.log("\n─── Cancellation ───"); 351 + 352 + const longTask = pipe( 353 + fiberId, 354 + flatMap((id) => { 355 + const loop = (n: number): Eff<string, never, unknown> => 356 + n <= 0 357 + ? succeed("completed") 358 + : pipe( 359 + sync(() => console.log(` Fiber ${id.id}: step ${5 - n + 1}`)), 360 + flatMap(() => sleep(50)), 361 + flatMap(() => yieldNow), 362 + flatMap(() => loop(n - 1)), 363 + ); 364 + return loop(5); 365 + }), 366 + ); 367 + 368 + const fiber = runFiber(longTask, {}); 369 + setTimeout(() => fiber.interrupt(), 120); 370 + 371 + const exit = await fiber.await(); 372 + console.log(`Exit: ${exit._tag}`); 373 + 374 + // ─── Dependency Injection ─── 375 + console.log("\n─── Dependency Injection ───"); 376 + 377 + type Logger = { log: (msg: string) => void }; 378 + type Env = { logger: Logger }; 379 + 380 + const program = pipe( 381 + accessEff<Env, void, never>(({ logger }) => 382 + sync(() => logger.log("Hello from injected logger!")), 383 + ), 384 + flatMap(() => succeed("Done")), 385 + ); 386 + 387 + const result = await runPromise( 388 + provide({ logger: { log: (m: string) => console.log(` [LOG] ${m}`) } })( 389 + program, 390 + ), 391 + ); 392 + console.log(`Result: ${result}`); 393 + 394 + // ─── Retry ─── 395 + console.log("\n─── Retry ───"); 396 + 397 + let attempts = 0; 398 + const flaky = pipe( 399 + sync(() => { 400 + attempts++; 401 + console.log(` Attempt ${attempts}`); 402 + if (attempts < 3) throw new Error("Flaky!"); 403 + return "success"; 404 + }), 405 + foldEff( 406 + (e) => fail(e), 407 + (a) => succeed(a), 408 + ), 409 + ); 410 + 411 + const retried = await runPromise(retry(5)(flaky)); 412 + console.log(`After retries: ${retried}`); 413 + }; 414 + 415 + // ============================================================================= 416 + // COMBINED EXAMPLE: Real-world workflow 417 + // ============================================================================= 418 + 419 + const realWorldDemo = async () => { 420 + console.log( 421 + "\n╔═══════════════════════════════════════════════════════════════╗", 422 + ); 423 + console.log( 424 + "║ REAL-WORLD EXAMPLE ║", 425 + ); 426 + console.log( 427 + "╚═══════════════════════════════════════════════════════════════╝", 428 + ); 429 + 430 + // Branded IDs 431 + type UserId = Branded<string, "UserId">; 432 + type OrderId = Branded<string, "OrderId">; 433 + 434 + // Error types 435 + type UserNotFound = { _tag: "UserNotFound"; userId: UserId }; 436 + type InvalidOrder = { _tag: "InvalidOrder"; reason: string }; 437 + type AppError = UserNotFound | InvalidOrder; 438 + 439 + // Services 440 + type UserService = { 441 + getUser: (id: UserId) => Eff<{ name: string }, UserNotFound, unknown>; 442 + }; 443 + 444 + type OrderService = { 445 + createOrder: ( 446 + userId: UserId, 447 + items: string[], 448 + ) => Eff<OrderId, InvalidOrder, unknown>; 449 + }; 450 + 451 + type Services = { users: UserService; orders: OrderService }; 452 + 453 + // Implementation 454 + const mockServices: Services = { 455 + users: { 456 + getUser: (id) => 457 + pipe( 458 + sleep(50), 459 + flatMap(() => 460 + id === brand("user-1") 461 + ? succeed({ name: "Alice" }) 462 + : fail<UserNotFound>({ _tag: "UserNotFound", userId: id }), 463 + ), 464 + ), 465 + }, 466 + orders: { 467 + createOrder: (userId, items) => 468 + pipe( 469 + sleep(50), 470 + flatMap(() => 471 + items.length > 0 472 + ? succeed(brand<string, "OrderId">(`order-${Date.now()}`)) 473 + : fail<InvalidOrder>({ 474 + _tag: "InvalidOrder", 475 + reason: "Empty cart", 476 + }), 477 + ), 478 + ), 479 + }, 480 + }; 481 + 482 + // Business logic 483 + const placeOrder = (userId: UserId, items: string[]) => 484 + accessEff<Services, OrderId, AppError>(({ users, orders }) => 485 + pipe( 486 + users.getUser(userId), 487 + flatMap((user) => 488 + pipe( 489 + sync(() => console.log(` Placing order for ${user.name}...`)), 490 + flatMap(() => orders.createOrder(userId, items)), 491 + ), 492 + ), 493 + ), 494 + ); 495 + 496 + // Run it 497 + console.log("\n─── Successful Order ───"); 498 + const order1 = await runPromise( 499 + pipe( 500 + placeOrder(brand("user-1"), ["Widget", "Gadget"]), 501 + provide(mockServices), 502 + timeout(1000), 503 + catchAll((e) => succeed(`Error: ${e._tag}` as any)), 504 + ), 505 + ); 506 + console.log(` Order: ${order1}`); 507 + 508 + console.log("\n─── User Not Found ───"); 509 + const order2 = await runPromise( 510 + pipe( 511 + placeOrder(brand("user-999"), ["Item"]), 512 + provide(mockServices), 513 + catchAll((e) => succeed(`Error: ${e._tag}`)), 514 + ), 515 + ); 516 + console.log(` Result: ${order2}`); 517 + 518 + console.log("\n─── Invalid Order ───"); 519 + const order3 = await runPromise( 520 + pipe( 521 + placeOrder(brand("user-1"), []), 522 + provide(mockServices), 523 + catchAll((e) => succeed(`Error: ${(e as InvalidOrder).reason}`)), 524 + ), 525 + ); 526 + console.log(` Result: ${order3}`); 527 + }; 528 + 529 + // ============================================================================= 530 + // RUN ALL DEMOS 531 + // ============================================================================= 532 + 533 + const main = async () => { 534 + foundationsDemo(); 535 + await effectDemo(); 536 + await realWorldDemo(); 537 + 538 + console.log( 539 + "\n╔═══════════════════════════════════════════════════════════════╗", 540 + ); 541 + console.log( 542 + "║ ✨ ALL DEMOS COMPLETE ✨ ║", 543 + ); 544 + console.log( 545 + "╚═══════════════════════════════════════════════════════════════╝", 546 + ); 547 + }; 548 + 549 + main().catch(console.error);
+678
examples/game-loop.ts
··· 1 + /** 2 + * Game Loop with Typed State and Physical Units 3 + * 4 + * Showcases: Typestate, units, tracked arrays, effect scheduling, pattern matching 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **Can't attack from Dead state (compile error)** 9 + * - Vanilla: `if (entity.state !== 'dead') entity.attack()` - runtime check 10 + * - Purus: `attack(entity: Entity<Alive>)` - can't call with dead entity 11 + * 12 + * 2. **Can't mix position with velocity (unit types)** 13 + * - Vanilla: `x += velocity` might accidentally add meters to meters/second 14 + * - Purus: `Position<Pixels>` + `Velocity<PixelsPerSecond>` - type error on mismatch 15 + * 16 + * 3. **Render list is guaranteed sorted at compile time** 17 + * - Vanilla: Hope you remembered to sort by z-index before rendering 18 + * - Purus: `Arr<Entity, Sorted>` - binarySearch only works on sorted arrays 19 + * 20 + * 4. **Game loop is just effect composition** 21 + * - Vanilla: `requestAnimationFrame` callback pyramid 22 + * - Purus: `pipe(update, flatMap(render), repeatEff(frames))` 23 + * 24 + * 5. **Entity state transitions are type-checked** 25 + * - Vanilla: String literals, easy to typo, no compiler help 26 + * - Purus: Phantom types enforce valid transitions 27 + * 28 + * 6. **Frame timing with sleep effect** 29 + * - Vanilla: Manual delta time calculation, setTimeout 30 + * - Purus: `sleep(16)` yields to scheduler, maintains frame rate 31 + */ 32 + 33 + import { 34 + // Types 35 + type Eff, 36 + type Exit, 37 + type Fiber, 38 + type Entity, 39 + type Quantity, 40 + type Arr, 41 + type Branded, 42 + type Refined, 43 + type Option, 44 + 45 + // Constructors 46 + succeed, 47 + fail, 48 + async, 49 + sync, 50 + entity, 51 + transition, 52 + quantity, 53 + arr, 54 + some, 55 + none, 56 + 57 + // Refinements 58 + positive, 59 + nonEmpty, 60 + 61 + // Array operations 62 + sortBy, 63 + filter, 64 + map, 65 + head, 66 + 67 + // Transformations 68 + mapEff, 69 + flatMap, 70 + foldEff, 71 + catchAll, 72 + 73 + // Concurrency 74 + fork, 75 + join, 76 + race, 77 + interruptFiber, 78 + 79 + // Combinators 80 + sleep, 81 + timeout, 82 + repeatEff, 83 + tapEff, 84 + 85 + // Pattern matching 86 + match, 87 + matchOption, 88 + 89 + // Composition 90 + pipe, 91 + brand, 92 + 93 + // Runners 94 + runFiber, 95 + runPromise, 96 + runPromiseExit, 97 + } from "../src/index" 98 + 99 + // ============================================================================= 100 + // PHYSICAL UNITS - Compile-time dimensional analysis 101 + // ============================================================================= 102 + 103 + /** 104 + * Physical unit types 105 + * 106 + * Vanilla problem: 107 + * ```ts 108 + * let x = 100 // pixels? meters? who knows! 109 + * let vx = 5 // pixels/frame? meters/second? 110 + * x += vx // Is this even valid? 111 + * ``` 112 + * 113 + * Purus solution: 114 + * ```ts 115 + * let x: Pixels = pixels(100) 116 + * let vx: PixelsPerSecond = pps(5) 117 + * x = addPixels(x, scaleByTime(vx, dt)) // Type-checked! 118 + * ``` 119 + */ 120 + 121 + // Unit type aliases 122 + type Pixels = "px" 123 + type Seconds = "s" 124 + type PixelsPerSecond = "px/s" 125 + type Degrees = "deg" 126 + type DegreesPerSecond = "deg/s" 127 + 128 + // Unit constructors 129 + const pixels = (n: number): Quantity<number, Pixels> => quantity<Pixels>(n) 130 + const seconds = (n: number): Quantity<number, Seconds> => quantity<Seconds>(n) 131 + const pps = (n: number): Quantity<number, PixelsPerSecond> => quantity<PixelsPerSecond>(n) 132 + const degrees = (n: number): Quantity<number, Degrees> => quantity<Degrees>(n) 133 + const dps = (n: number): Quantity<number, DegreesPerSecond> => quantity<DegreesPerSecond>(n) 134 + 135 + // Unit arithmetic 136 + const addPixels = (a: Quantity<number, Pixels>, b: Quantity<number, Pixels>): Quantity<number, Pixels> => 137 + quantity<Pixels>((a as number) + (b as number)) 138 + 139 + const subPixels = (a: Quantity<number, Pixels>, b: Quantity<number, Pixels>): Quantity<number, Pixels> => 140 + quantity<Pixels>((a as number) - (b as number)) 141 + 142 + const scaleVelocity = (v: Quantity<number, PixelsPerSecond>, t: Quantity<number, Seconds>): Quantity<number, Pixels> => 143 + quantity<Pixels>((v as number) * (t as number)) 144 + 145 + const addDegrees = (a: Quantity<number, Degrees>, b: Quantity<number, Degrees>): Quantity<number, Degrees> => 146 + quantity<Degrees>(((a as number) + (b as number)) % 360) 147 + 148 + const scaleRotation = (r: Quantity<number, DegreesPerSecond>, t: Quantity<number, Seconds>): Quantity<number, Degrees> => 149 + quantity<Degrees>((r as number) * (t as number)) 150 + 151 + // ============================================================================= 152 + // ENTITY ID 153 + // ============================================================================= 154 + 155 + type EntityId = Branded<string, "EntityId"> 156 + const EntityId = (): EntityId => brand(`entity-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 157 + 158 + // ============================================================================= 159 + // GAME ENTITY TYPESTATE - State machine for entity lifecycle 160 + // ============================================================================= 161 + 162 + /** 163 + * Entity states as phantom types 164 + * 165 + * Vanilla problem: 166 + * ```ts 167 + * type Entity = { 168 + * state: "idle" | "moving" | "attacking" | "dead" 169 + * } 170 + * 171 + * function attack(entity: Entity) { 172 + * if (entity.state === "dead") throw new Error("Can't attack when dead") 173 + * // ... 174 + * } 175 + * ``` 176 + * 177 + * Purus solution: 178 + * ```ts 179 + * type DeadEntity = Entity<EntityData, "Dead"> 180 + * 181 + * function attack(entity: Entity<EntityData, "Idle" | "Moving">): Entity<EntityData, "Attacking"> { 182 + * // Can ONLY be called with non-dead entities 183 + * // Compiler enforces this! 184 + * } 185 + * 186 + * // This is a compile error: 187 + * attack(deadEntity) // Type 'DeadEntity' is not assignable to 'IdleEntity | MovingEntity' 188 + * ``` 189 + */ 190 + 191 + type Idle = "Idle" 192 + type Moving = "Moving" 193 + type Attacking = "Attacking" 194 + type Stunned = "Stunned" 195 + type Dead = "Dead" 196 + 197 + type AliveState = Idle | Moving | Attacking | Stunned 198 + type GameEntityState = AliveState | Dead 199 + 200 + type EntityData = { 201 + readonly id: EntityId 202 + readonly name: string 203 + readonly x: Quantity<number, Pixels> 204 + readonly y: Quantity<number, Pixels> 205 + readonly vx: Quantity<number, PixelsPerSecond> 206 + readonly vy: Quantity<number, PixelsPerSecond> 207 + readonly rotation: Quantity<number, Degrees> 208 + readonly rotationSpeed: Quantity<number, DegreesPerSecond> 209 + readonly health: number 210 + readonly maxHealth: number 211 + readonly zIndex: number 212 + } 213 + 214 + type IdleEntity = Entity<EntityData, Idle> 215 + type MovingEntity = Entity<EntityData, Moving> 216 + type AttackingEntity = Entity<EntityData, Attacking> 217 + type StunnedEntity = Entity<EntityData, Stunned> 218 + type DeadEntity = Entity<EntityData, Dead> 219 + 220 + type GameEntity = IdleEntity | MovingEntity | AttackingEntity | StunnedEntity | DeadEntity 221 + 222 + // State transition functions 223 + const toIdle = <S extends AliveState>() => transition<EntityData, S, Idle>() 224 + const toMoving = <S extends AliveState>() => transition<EntityData, S, Moving>() 225 + const toAttacking = <S extends Idle | Moving>() => transition<EntityData, S, Attacking>() 226 + const toStunned = <S extends AliveState>() => transition<EntityData, S, Stunned>() 227 + const toDead = <S extends AliveState>() => transition<EntityData, S, Dead>() 228 + 229 + // ============================================================================= 230 + // ENTITY FACTORIES 231 + // ============================================================================= 232 + 233 + const createEntity = ( 234 + name: string, 235 + x: number, 236 + y: number, 237 + zIndex: number = 0 238 + ): IdleEntity => 239 + entity<EntityData, Idle>({ 240 + id: EntityId(), 241 + name, 242 + x: pixels(x), 243 + y: pixels(y), 244 + vx: pps(0), 245 + vy: pps(0), 246 + rotation: degrees(0), 247 + rotationSpeed: dps(0), 248 + health: 100, 249 + maxHealth: 100, 250 + zIndex, 251 + }) 252 + 253 + // ============================================================================= 254 + // GAME ACTIONS - Type-safe state transitions 255 + // ============================================================================= 256 + 257 + /** 258 + * Start moving an entity 259 + * 260 + * Note: Can only move Idle or Stunned entities 261 + * Dead entities can't move - enforced at compile time! 262 + */ 263 + const startMoving = ( 264 + entity: IdleEntity | StunnedEntity, 265 + vx: Quantity<number, PixelsPerSecond>, 266 + vy: Quantity<number, PixelsPerSecond> 267 + ): MovingEntity => 268 + transition<EntityData, Idle | Stunned, Moving>(e => ({ 269 + ...e, 270 + vx, 271 + vy, 272 + }))(entity) 273 + 274 + /** 275 + * Stop moving an entity 276 + */ 277 + const stopMoving = (entity: MovingEntity): IdleEntity => 278 + transition<EntityData, Moving, Idle>(e => ({ 279 + ...e, 280 + vx: pps(0), 281 + vy: pps(0), 282 + }))(entity) 283 + 284 + /** 285 + * Attack - can only attack from Idle or Moving state 286 + * 287 + * Try calling this with a DeadEntity - you'll get a compile error! 288 + */ 289 + const attack = (entity: IdleEntity | MovingEntity): AttackingEntity => 290 + toAttacking<Idle | Moving>()(entity) 291 + 292 + /** 293 + * Finish attack animation 294 + */ 295 + const finishAttack = (entity: AttackingEntity): IdleEntity => 296 + toIdle<Attacking>()(entity) 297 + 298 + /** 299 + * Take damage - may transition to Dead 300 + */ 301 + const takeDamage = <S extends AliveState>( 302 + entity: Entity<EntityData, S>, 303 + damage: number 304 + ): Entity<EntityData, S> | DeadEntity => { 305 + const newHealth = entity.health - damage 306 + if (newHealth <= 0) { 307 + return toDead<S>()(entity) 308 + } 309 + return { ...entity, health: newHealth } as Entity<EntityData, S> 310 + } 311 + 312 + /** 313 + * Stun an entity 314 + */ 315 + const stun = <S extends AliveState>(entity: Entity<EntityData, S>): StunnedEntity => 316 + toStunned<S>()(entity) 317 + 318 + /** 319 + * Recover from stun 320 + */ 321 + const recoverFromStun = (entity: StunnedEntity): IdleEntity => 322 + toIdle<Stunned>()(entity) 323 + 324 + // ============================================================================= 325 + // PHYSICS UPDATE - Type-safe position/velocity calculations 326 + // ============================================================================= 327 + 328 + /** 329 + * Update entity position based on velocity and delta time 330 + * 331 + * This is where unit types shine: 332 + * - Can't accidentally add velocity to position without scaling by time 333 + * - All arithmetic is type-checked 334 + */ 335 + const updatePhysics = <S extends GameEntityState>( 336 + entity: Entity<EntityData, S>, 337 + deltaTime: Quantity<number, Seconds> 338 + ): Entity<EntityData, S> => { 339 + const dx = scaleVelocity(entity.vx, deltaTime) 340 + const dy = scaleVelocity(entity.vy, deltaTime) 341 + const dRotation = scaleRotation(entity.rotationSpeed, deltaTime) 342 + 343 + return { 344 + ...entity, 345 + x: addPixels(entity.x, dx), 346 + y: addPixels(entity.y, dy), 347 + rotation: addDegrees(entity.rotation, dRotation), 348 + } as Entity<EntityData, S> 349 + } 350 + 351 + // ============================================================================= 352 + // RENDER SORTING - Guaranteed sorted at compile time 353 + // ============================================================================= 354 + 355 + /** 356 + * Sort entities by z-index for rendering 357 + * 358 + * The return type `Arr<GameEntity, Sorted>` guarantees the array is sorted. 359 + * Functions that need a sorted array (like binarySearch) can require this type. 360 + */ 361 + const sortByZIndex = (entities: readonly GameEntity[]): Arr<GameEntity, "Sorted"> => 362 + sortBy<GameEntity, never>(e => e.zIndex)(arr(entities)) 363 + 364 + /** 365 + * Get the topmost entity (highest z-index) 366 + * 367 + * Note: This requires a NonEmpty array - can't get topmost from empty! 368 + */ 369 + const getTopmost = (entities: Arr<GameEntity, "Sorted" | "NonEmpty">): GameEntity => 370 + head(entities) 371 + 372 + // ============================================================================= 373 + // GAME LOOP - Effect-based frame scheduling 374 + // ============================================================================= 375 + 376 + type GameState = { 377 + readonly entities: readonly GameEntity[] 378 + readonly frameCount: number 379 + readonly lastUpdateTime: number 380 + } 381 + 382 + const createGameState = (entities: readonly GameEntity[]): GameState => ({ 383 + entities, 384 + frameCount: 0, 385 + lastUpdateTime: Date.now(), 386 + }) 387 + 388 + /** 389 + * Single game frame update 390 + */ 391 + const updateFrame = (state: GameState): Eff<GameState, never, unknown> => 392 + sync(() => { 393 + const now = Date.now() 394 + const deltaMs = now - state.lastUpdateTime 395 + const deltaTime = seconds(deltaMs / 1000) 396 + 397 + // Update all entity physics 398 + const updatedEntities = state.entities.map(entity => updatePhysics(entity, deltaTime)) 399 + 400 + return { 401 + entities: updatedEntities, 402 + frameCount: state.frameCount + 1, 403 + lastUpdateTime: now, 404 + } 405 + }) 406 + 407 + /** 408 + * Render a single frame 409 + */ 410 + const renderFrame = (state: GameState): Eff<void, never, unknown> => 411 + sync(() => { 412 + // Sort by z-index for proper rendering order 413 + const sorted = sortByZIndex(state.entities) 414 + 415 + // Log frame info (in real game, this would draw to canvas) 416 + if (state.frameCount % 60 === 0) { 417 + console.log(` Frame ${state.frameCount}:`) 418 + sorted.forEach(e => { 419 + const stateTag = getEntityState(e) 420 + console.log(` ${e.name} (${stateTag}): pos=(${Math.round(e.x as number)}, ${Math.round(e.y as number)}), health=${e.health}`) 421 + }) 422 + } 423 + }) 424 + 425 + /** 426 + * Get entity state tag (for debugging) 427 + */ 428 + const getEntityState = (entity: GameEntity): string => { 429 + if (entity.health <= 0) return "Dead" 430 + if ((entity.vx as number) !== 0 || (entity.vy as number) !== 0) return "Moving" 431 + return "Idle" 432 + } 433 + 434 + /** 435 + * Game loop - runs at ~60fps 436 + */ 437 + const gameLoop = (initialState: GameState, maxFrames: number): Eff<GameState, never, unknown> => { 438 + const loop = (state: GameState): Eff<GameState, never, unknown> => { 439 + if (state.frameCount >= maxFrames) { 440 + return succeed(state) 441 + } 442 + 443 + return pipe( 444 + updateFrame(state), 445 + flatMap(newState => 446 + pipe( 447 + renderFrame(newState), 448 + flatMap(() => sleep(16)), // ~60 FPS 449 + flatMap(() => loop(newState)) 450 + ) 451 + ) 452 + ) 453 + } 454 + 455 + return loop(initialState) 456 + } 457 + 458 + // ============================================================================= 459 + // INPUT HANDLING - Effect-based event processing 460 + // ============================================================================= 461 + 462 + type InputEvent = 463 + | { readonly _tag: "Move"; readonly entityId: EntityId; readonly vx: number; readonly vy: number } 464 + | { readonly _tag: "Attack"; readonly entityId: EntityId } 465 + | { readonly _tag: "Stop"; readonly entityId: EntityId } 466 + 467 + const InputEvent = { 468 + move: (entityId: EntityId, vx: number, vy: number): InputEvent => 469 + ({ _tag: "Move", entityId, vx, vy }), 470 + attack: (entityId: EntityId): InputEvent => 471 + ({ _tag: "Attack", entityId }), 472 + stop: (entityId: EntityId): InputEvent => 473 + ({ _tag: "Stop", entityId }), 474 + } 475 + 476 + /** 477 + * Process input event 478 + * 479 + * Pattern matching ensures all input types are handled 480 + */ 481 + const processInput = (state: GameState, input: InputEvent): GameState => { 482 + const updateEntity = (id: EntityId, updater: (e: GameEntity) => GameEntity): readonly GameEntity[] => 483 + state.entities.map(e => e.id === id ? updater(e) : e) 484 + 485 + return match(input)({ 486 + Move: ({ entityId, vx, vy }) => ({ 487 + ...state, 488 + entities: updateEntity(entityId, e => { 489 + // Only alive entities can move 490 + if (e.health <= 0) return e 491 + // Type-safe transition 492 + const idle = e as IdleEntity 493 + return startMoving(idle, pps(vx), pps(vy)) 494 + }), 495 + }), 496 + 497 + Attack: ({ entityId }) => ({ 498 + ...state, 499 + entities: updateEntity(entityId, e => { 500 + if (e.health <= 0) return e 501 + const alive = e as IdleEntity | MovingEntity 502 + return attack(alive) 503 + }), 504 + }), 505 + 506 + Stop: ({ entityId }) => ({ 507 + ...state, 508 + entities: updateEntity(entityId, e => { 509 + if (e.health <= 0) return e 510 + if ((e.vx as number) === 0 && (e.vy as number) === 0) return e 511 + const moving = e as MovingEntity 512 + return stopMoving(moving) 513 + }), 514 + }), 515 + }) 516 + } 517 + 518 + // ============================================================================= 519 + // COMBAT SYSTEM - Demonstrating typestate benefits 520 + // ============================================================================= 521 + 522 + /** 523 + * Combat round between two entities 524 + * 525 + * This demonstrates why typestate is powerful: 526 + * - Can only initiate combat with alive entities 527 + * - Result can transition entities to Dead 528 + * - All state changes are type-tracked 529 + */ 530 + const resolveCombat = ( 531 + attacker: IdleEntity | MovingEntity, 532 + defender: IdleEntity | MovingEntity | StunnedEntity, 533 + damage: number 534 + ): { attacker: AttackingEntity; defender: GameEntity } => { 535 + const attackingEntity = attack(attacker) 536 + const damagedDefender = takeDamage(defender, damage) 537 + 538 + return { 539 + attacker: attackingEntity, 540 + defender: damagedDefender, 541 + } 542 + } 543 + 544 + // ============================================================================= 545 + // DEMO 546 + // ============================================================================= 547 + 548 + const demo = async () => { 549 + console.log("╔═══════════════════════════════════════════════════════════════╗") 550 + console.log("║ GAME LOOP DEMO - purus-ts ║") 551 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 552 + 553 + // 1. Create entities 554 + console.log("─── 1. Create Game Entities ───") 555 + 556 + const player = createEntity("Player", 100, 100, 10) 557 + const enemy1 = createEntity("Goblin", 300, 150, 5) 558 + const enemy2 = createEntity("Orc", 200, 300, 5) 559 + const tree = createEntity("Tree", 400, 200, 1) 560 + 561 + console.log(" Created entities:") 562 + console.log(` - Player at (${player.x as number}, ${player.y as number})`) 563 + console.log(` - Goblin at (${enemy1.x as number}, ${enemy1.y as number})`) 564 + console.log(` - Orc at (${enemy2.x as number}, ${enemy2.y as number})`) 565 + console.log(` - Tree at (${tree.x as number}, ${tree.y as number})\n`) 566 + 567 + // 2. Demonstrate typestate transitions 568 + console.log("─── 2. Typestate Transitions ───") 569 + 570 + // Move the player 571 + const movingPlayer = startMoving(player, pps(50), pps(25)) 572 + console.log(` Player started moving: vx=${movingPlayer.vx}, vy=${movingPlayer.vy}`) 573 + 574 + // Attack from moving state 575 + const attackingPlayer = attack(movingPlayer) 576 + console.log(` Player is now attacking`) 577 + 578 + // Finish attack and go idle 579 + const idlePlayer = finishAttack(attackingPlayer) 580 + console.log(` Player finished attack, now idle`) 581 + 582 + // Combat - deal damage 583 + const combatResult = resolveCombat(idlePlayer, enemy1 as IdleEntity, 30) 584 + console.log(` Combat! Player attacks Goblin for 30 damage`) 585 + console.log(` Goblin health: ${combatResult.defender.health}/${enemy1.maxHealth}`) 586 + 587 + // Try to attack a dead entity - this would be a compile error: 588 + // const deadEnemy = takeDamage(enemy1 as IdleEntity, 200) as DeadEntity 589 + // attack(deadEnemy) // ERROR: Type 'DeadEntity' is not assignable 590 + 591 + console.log(" ✓ Typestate prevents attacking dead entities (compile-time check)\n") 592 + 593 + // 3. Unit type safety 594 + console.log("─── 3. Physical Unit Safety ───") 595 + 596 + const pos1 = pixels(100) 597 + const pos2 = pixels(50) 598 + const vel = pps(10) 599 + const time = seconds(0.5) 600 + 601 + const displacement = scaleVelocity(vel, time) 602 + const newPos = addPixels(pos1, displacement) 603 + 604 + console.log(` Position: ${pos1}px + (${vel}px/s × ${time}s) = ${newPos}px`) 605 + 606 + // These would be compile errors: 607 + // addPixels(pos1, vel) // ERROR: Pixels + PixelsPerSecond 608 + // scaleVelocity(pos1, time) // ERROR: Pixels is not velocity 609 + 610 + console.log(" ✓ Unit types prevent mixing position and velocity\n") 611 + 612 + // 4. Sorted array guarantee 613 + console.log("─── 4. Sorted Array for Rendering ───") 614 + 615 + const entities: readonly GameEntity[] = [enemy2, tree, player, enemy1] 616 + const sortedEntities = sortByZIndex(entities) 617 + 618 + console.log(" Render order (by z-index):") 619 + sortedEntities.forEach((e, i) => { 620 + console.log(` ${i + 1}. ${e.name} (z=${e.zIndex})`) 621 + }) 622 + 623 + // Check if non-empty to get topmost 624 + const nonEmptyResult = nonEmpty(sortedEntities) 625 + matchOption( 626 + (entities: Arr<GameEntity, "Sorted" | "NonEmpty">) => { 627 + const topmost = getTopmost(entities) 628 + console.log(` Topmost entity: ${topmost.name}`) 629 + }, 630 + () => console.log(" No entities to render") 631 + )(nonEmptyResult) 632 + 633 + console.log() 634 + 635 + // 5. Run game loop 636 + console.log("─── 5. Game Loop (120 frames ≈ 2 seconds) ───") 637 + 638 + const initialEntities: readonly GameEntity[] = [ 639 + startMoving(player, pps(30), pps(15)), 640 + startMoving(enemy1 as IdleEntity, pps(-20), pps(10)), 641 + enemy2, 642 + tree, 643 + ] 644 + 645 + const initialState = createGameState(initialEntities) 646 + 647 + const loopFiber = runFiber(gameLoop(initialState, 120), undefined) 648 + 649 + const finalState = await loopFiber.await() 650 + if (finalState._tag === "Success") { 651 + console.log(`\n ✓ Game loop completed: ${finalState.value.frameCount} frames`) 652 + } 653 + console.log() 654 + 655 + // 6. Input processing 656 + console.log("─── 6. Input Processing ───") 657 + 658 + let gameState = createGameState([player, enemy1]) 659 + 660 + // Simulate inputs 661 + const inputs: InputEvent[] = [ 662 + InputEvent.move(player.id, 100, 50), 663 + InputEvent.attack(player.id), 664 + InputEvent.stop(player.id), 665 + ] 666 + 667 + inputs.forEach(input => { 668 + gameState = processInput(gameState, input) 669 + console.log(` Processed: ${input._tag}`) 670 + }) 671 + 672 + console.log(" ✓ Pattern matching handles all input types\n") 673 + 674 + console.log("═══════════════════════════════════════════════════════════════") 675 + console.log("Demo complete!") 676 + } 677 + 678 + demo().catch(console.error)
+513
examples/http-client.ts
··· 1 + /** 2 + * Resilient HTTP Client 3 + * 4 + * Showcases: Effect composition, retry, timeout, race, typed error handling, dependency injection 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **Typed errors force handling all failure modes** 9 + * - Vanilla: `catch (e)` gives you `unknown`, easy to miss error cases 10 + * - Purus: `Eff<Response, HttpError, Env>` - compiler enforces handling NotFound, Timeout, etc. 11 + * 12 + * 2. **Race + timeout with automatic cleanup** 13 + * - Vanilla: Promise.race() doesn't cancel the loser, AbortController is manual 14 + * - Purus: `race()` automatically interrupts the losing fiber, cleanup runs 15 + * 16 + * 3. **Retry logic is composable, not scattered try/catch** 17 + * - Vanilla: Retry logic is copy-pasted, exponential backoff is complex 18 + * - Purus: `pipe(request, retry(3), timeout(5000))` - one line 19 + * 20 + * 4. **Dependency injection without class hierarchies** 21 + * - Vanilla: Constructor injection, DI frameworks, decorators 22 + * - Purus: `access<Env>()` + `provide(env)` - pure functions, no magic 23 + * 24 + * 5. **Parallel requests with fail-fast semantics** 25 + * - Vanilla: Promise.all() continues running failed requests 26 + * - Purus: `all()` cancels all on first failure, cleans up resources 27 + */ 28 + 29 + import { 30 + // Types 31 + type Eff, 32 + type Exit, 33 + type Branded, 34 + 35 + // Constructors 36 + succeed, 37 + fail, 38 + async, 39 + sync, 40 + 41 + // Transformations 42 + mapEff, 43 + flatMap, 44 + foldEff, 45 + catchAll, 46 + access, 47 + accessEff, 48 + provide, 49 + 50 + // Concurrency 51 + race, 52 + all, 53 + 54 + // Combinators 55 + sleep, 56 + timeout, 57 + retry, 58 + 59 + // Composition 60 + pipe, 61 + match, 62 + brand, 63 + 64 + // Runners 65 + runPromise, 66 + } from "../src/index" 67 + 68 + // ============================================================================= 69 + // DOMAIN TYPES - Branded IDs prevent mixing up different string types 70 + // ============================================================================= 71 + 72 + /** 73 + * URL branded type - can't accidentally pass a UserId where URL is expected 74 + * 75 + * Vanilla problem: 76 + * fetch(userId) // Compiles! But wrong at runtime 77 + * 78 + * Purus solution: 79 + * fetch(userId) // Type error! Expected Url, got UserId 80 + */ 81 + type Url = Branded<string, "Url"> 82 + const Url = (s: string): Url => brand(s) 83 + 84 + type RequestId = Branded<string, "RequestId"> 85 + const RequestId = (): RequestId => brand(crypto.randomUUID()) 86 + 87 + // ============================================================================= 88 + // TYPED ERROR HIERARCHY - Compiler forces you to handle all cases 89 + // ============================================================================= 90 + 91 + /** 92 + * Discriminated union of all possible HTTP errors 93 + * 94 + * Vanilla problem: 95 + * try { await fetch(url) } catch (e) { /* what is e? unknown! */ } 96 + * 97 + * Purus solution: 98 + * The type signature `Eff<Response, HttpError, Env>` makes it clear 99 + * what errors can occur, and `match()` forces handling all of them 100 + */ 101 + type HttpError = 102 + | { readonly _tag: "NetworkError"; readonly message: string } 103 + | { readonly _tag: "TimeoutError"; readonly url: Url; readonly ms: number } 104 + | { readonly _tag: "NotFound"; readonly url: Url } 105 + | { readonly _tag: "Unauthorized"; readonly url: Url } 106 + | { readonly _tag: "RateLimited"; readonly url: Url; readonly retryAfter: number } 107 + | { readonly _tag: "ServerError"; readonly url: Url; readonly status: number } 108 + | { readonly _tag: "ParseError"; readonly message: string } 109 + 110 + const HttpError = { 111 + network: (message: string): HttpError => ({ _tag: "NetworkError", message }), 112 + timeout: (url: Url, ms: number): HttpError => ({ _tag: "TimeoutError", url, ms }), 113 + notFound: (url: Url): HttpError => ({ _tag: "NotFound", url }), 114 + unauthorized: (url: Url): HttpError => ({ _tag: "Unauthorized", url }), 115 + rateLimited: (url: Url, retryAfter: number): HttpError => ({ _tag: "RateLimited", url, retryAfter }), 116 + serverError: (url: Url, status: number): HttpError => ({ _tag: "ServerError", url, status }), 117 + parse: (message: string): HttpError => ({ _tag: "ParseError", message }), 118 + } 119 + 120 + // ============================================================================= 121 + // ENVIRONMENT TYPES - Dependency injection via type parameters 122 + // ============================================================================= 123 + 124 + /** 125 + * Services required by our HTTP client 126 + * 127 + * Vanilla problem: 128 + * - Constructor injection couples caller to implementation 129 + * - Global singletons make testing hard 130 + * - DI frameworks add magic and complexity 131 + * 132 + * Purus solution: 133 + * - `access<HttpEnv>()` reads from environment 134 + * - `provide(mockEnv)(effect)` injects mocks for testing 135 + * - No decorators, no reflection, just functions 136 + */ 137 + type Logger = { 138 + readonly debug: (msg: string) => void 139 + readonly info: (msg: string) => void 140 + readonly error: (msg: string) => void 141 + } 142 + 143 + type Metrics = { 144 + readonly requestStart: (id: RequestId, url: Url) => void 145 + readonly requestEnd: (id: RequestId, status: "success" | "failure", durationMs: number) => void 146 + } 147 + 148 + type HttpEnv = { 149 + readonly logger: Logger 150 + readonly metrics: Metrics 151 + readonly baseUrl: Url 152 + } 153 + 154 + // ============================================================================= 155 + // CORE HTTP PRIMITIVES 156 + // ============================================================================= 157 + 158 + /** 159 + * Low-level fetch wrapper with automatic cleanup on cancellation 160 + * 161 + * Vanilla problem: 162 + * - AbortController requires manual cleanup 163 + * - Easy to forget, causes memory leaks 164 + * - No standard way to propagate cancellation 165 + * 166 + * Purus solution: 167 + * - Return a cleanup function from `async()` 168 + * - Runtime automatically calls it on interrupt 169 + * - Cancellation propagates through the entire fiber tree 170 + */ 171 + const fetchWithAbort = (url: Url, options: RequestInit = {}): Eff<Response, HttpError, unknown> => 172 + async((resume) => { 173 + const controller = new AbortController() 174 + 175 + fetch(url, { ...options, signal: controller.signal }) 176 + .then(response => { 177 + if (response.ok) { 178 + resume(Exit.succeed(response)) 179 + } else if (response.status === 404) { 180 + resume(Exit.fail(HttpError.notFound(url))) 181 + } else if (response.status === 401) { 182 + resume(Exit.fail(HttpError.unauthorized(url))) 183 + } else if (response.status === 429) { 184 + const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10) 185 + resume(Exit.fail(HttpError.rateLimited(url, retryAfter))) 186 + } else if (response.status >= 500) { 187 + resume(Exit.fail(HttpError.serverError(url, response.status))) 188 + } else { 189 + resume(Exit.fail(HttpError.serverError(url, response.status))) 190 + } 191 + }) 192 + .catch(err => { 193 + if (err.name === "AbortError") { 194 + // Interrupted - don't resume, fiber is already dead 195 + return 196 + } 197 + resume(Exit.fail(HttpError.network(err.message))) 198 + }) 199 + 200 + // Cleanup function - called automatically on fiber interruption! 201 + return () => controller.abort() 202 + }) 203 + 204 + /** 205 + * Parse JSON response with typed error handling 206 + */ 207 + const parseJson = <T>(response: Response): Eff<T, HttpError, unknown> => 208 + async((resume) => { 209 + response 210 + .json() 211 + .then(data => resume(Exit.succeed(data as T))) 212 + .catch(err => resume(Exit.fail(HttpError.parse(err.message)))) 213 + }) 214 + 215 + // ============================================================================= 216 + // HIGH-LEVEL HTTP CLIENT - Composable, logged, metriced 217 + // ============================================================================= 218 + 219 + type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" 220 + 221 + type RequestConfig = { 222 + readonly method: HttpMethod 223 + readonly body?: unknown 224 + readonly headers?: Record<string, string> 225 + readonly timeoutMs?: number 226 + readonly retries?: number 227 + } 228 + 229 + /** 230 + * Make an HTTP request with logging, metrics, timeout, and retry 231 + * 232 + * This is where purus shines - look how cleanly this composes! 233 + * 234 + * Vanilla equivalent would be: 235 + * try { 236 + * const controller = new AbortController() 237 + * const timeoutId = setTimeout(() => controller.abort(), timeoutMs) 238 + * for (let attempt = 0; attempt < retries; attempt++) { 239 + * try { 240 + * logger.debug(`Attempt ${attempt}...`) 241 + * const response = await fetch(url, { signal: controller.signal }) 242 + * clearTimeout(timeoutId) 243 + * metrics.requestEnd(...) 244 + * return await response.json() 245 + * } catch (e) { 246 + * if (attempt === retries - 1) throw e 247 + * await sleep(1000 * Math.pow(2, attempt)) 248 + * } 249 + * } 250 + * } catch (e) { ... } 251 + * 252 + * With purus, it's just `pipe(fetch, retry(3), timeout(5000))` 253 + */ 254 + const request = <T>(path: string, config: RequestConfig = { method: "GET" }): Eff<T, HttpError, HttpEnv> => 255 + accessEff<HttpEnv, T, HttpError>(({ logger, metrics, baseUrl }) => { 256 + const url = Url(`${baseUrl}${path}`) 257 + const id = RequestId() 258 + const timeoutMs = config.timeoutMs ?? 30000 259 + const retries = config.retries ?? 3 260 + 261 + const startTime = Date.now() 262 + 263 + // Log the request 264 + const logStart = sync(() => { 265 + logger.info(`[${id}] ${config.method} ${url}`) 266 + metrics.requestStart(id, url) 267 + }) 268 + 269 + // The actual fetch + parse 270 + const doRequest = pipe( 271 + fetchWithAbort(url, { 272 + method: config.method, 273 + headers: { 274 + "Content-Type": "application/json", 275 + ...config.headers, 276 + }, 277 + body: config.body ? JSON.stringify(config.body) : undefined, 278 + }), 279 + flatMap(response => parseJson<T>(response)) 280 + ) 281 + 282 + // Compose: log → fetch → retry → timeout → log result 283 + return pipe( 284 + logStart, 285 + flatMap(() => 286 + pipe( 287 + doRequest, 288 + retry(retries), 289 + timeout(timeoutMs), 290 + // Convert timeout (null) to TimeoutError 291 + flatMap(result => 292 + result === null 293 + ? fail(HttpError.timeout(url, timeoutMs)) 294 + : succeed(result) 295 + ), 296 + // Log success/failure 297 + foldEff( 298 + error => { 299 + logger.error(`[${id}] Failed: ${error._tag}`) 300 + metrics.requestEnd(id, "failure", Date.now() - startTime) 301 + return fail(error) 302 + }, 303 + data => { 304 + logger.info(`[${id}] Success in ${Date.now() - startTime}ms`) 305 + metrics.requestEnd(id, "success", Date.now() - startTime) 306 + return succeed(data) 307 + } 308 + ) 309 + ) 310 + ) 311 + ) 312 + }) 313 + 314 + // Convenience methods 315 + const get = <T>(path: string, config?: Omit<RequestConfig, "method">): Eff<T, HttpError, HttpEnv> => 316 + request<T>(path, { ...config, method: "GET" }) 317 + 318 + const post = <T>(path: string, body: unknown, config?: Omit<RequestConfig, "method" | "body">): Eff<T, HttpError, HttpEnv> => 319 + request<T>(path, { ...config, method: "POST", body }) 320 + 321 + // ============================================================================= 322 + // ADVANCED PATTERNS 323 + // ============================================================================= 324 + 325 + /** 326 + * Race multiple servers - use the fastest response 327 + * 328 + * Vanilla problem: 329 + * - Promise.race() doesn't cancel slower requests 330 + * - They continue running, wasting resources 331 + * - Need manual AbortController coordination 332 + * 333 + * Purus solution: 334 + * - `race()` automatically interrupts the loser 335 + * - Cleanup functions run, connections close 336 + * - One line of code 337 + */ 338 + const fastestServer = <T>(urls: readonly Url[]): Eff<T, HttpError, unknown> => { 339 + if (urls.length === 0) { 340 + return fail(HttpError.network("No URLs provided")) 341 + } 342 + if (urls.length === 1) { 343 + return fetchWithAbort(urls[0]!).pipe(flatMap(r => parseJson<T>(r))) 344 + } 345 + 346 + const requests = urls.map(url => 347 + pipe( 348 + fetchWithAbort(url), 349 + flatMap(r => parseJson<T>(r)) 350 + ) 351 + ) 352 + 353 + // Race all requests - first to complete wins, others are cancelled 354 + return requests.reduce((a, b) => race(a, b)) 355 + } 356 + 357 + /** 358 + * Parallel batch requests with fail-fast 359 + * 360 + * Vanilla problem: 361 + * - Promise.all() waits for all, even after first failure 362 + * - Promise.allSettled() doesn't cancel on failure 363 + * - Manual coordination is complex and error-prone 364 + * 365 + * Purus solution: 366 + * - `all()` runs in parallel 367 + * - First failure cancels all other requests 368 + * - Resources are cleaned up automatically 369 + */ 370 + const batchGet = <T>(paths: readonly string[]): Eff<readonly T[], HttpError, HttpEnv> => 371 + all(paths.map(path => get<T>(path))) as Eff<readonly T[], HttpError, HttpEnv> 372 + 373 + /** 374 + * Retry with exponential backoff 375 + * 376 + * Vanilla: Complex loop with setTimeout, try/catch, counter management 377 + * Purus: Just compose `sleep` and `retry` effects 378 + */ 379 + const retryWithBackoff = <A, E, R>( 380 + effect: Eff<A, E, R>, 381 + maxAttempts: number, 382 + baseDelayMs: number 383 + ): Eff<A, E, R> => { 384 + const attempt = (n: number): Eff<A, E, R> => 385 + n >= maxAttempts 386 + ? effect 387 + : pipe( 388 + effect, 389 + catchAll(error => 390 + pipe( 391 + sleep(baseDelayMs * Math.pow(2, n)), 392 + flatMap(() => attempt(n + 1)) 393 + ) as Eff<A, E, R> 394 + ) 395 + ) as Eff<A, E, R> 396 + 397 + return attempt(0) 398 + } 399 + 400 + // ============================================================================= 401 + // ERROR HANDLING WITH PATTERN MATCHING 402 + // ============================================================================= 403 + 404 + /** 405 + * Handle all error cases exhaustively 406 + * 407 + * Vanilla problem: 408 + * - `catch (e: unknown)` - you don't know what errors can occur 409 + * - Easy to forget edge cases 410 + * - No compiler help 411 + * 412 + * Purus solution: 413 + * - `match()` requires handling ALL variants 414 + * - Add a new error type → compiler errors until you handle it 415 + * - Exhaustive by construction 416 + */ 417 + const handleHttpError = (error: HttpError): string => 418 + match(error)({ 419 + NetworkError: ({ message }) => `Network failed: ${message}`, 420 + TimeoutError: ({ url, ms }) => `Request to ${url} timed out after ${ms}ms`, 421 + NotFound: ({ url }) => `Resource not found: ${url}`, 422 + Unauthorized: ({ url }) => `Unauthorized access to ${url}`, 423 + RateLimited: ({ url, retryAfter }) => `Rate limited on ${url}, retry after ${retryAfter}s`, 424 + ServerError: ({ url, status }) => `Server error ${status} on ${url}`, 425 + ParseError: ({ message }) => `Failed to parse response: ${message}`, 426 + }) 427 + 428 + // ============================================================================= 429 + // DEMO - Run it! 430 + // ============================================================================= 431 + 432 + // Mock environment for demo 433 + const mockEnv: HttpEnv = { 434 + logger: { 435 + debug: msg => console.log(`[DEBUG] ${msg}`), 436 + info: msg => console.log(`[INFO] ${msg}`), 437 + error: msg => console.log(`[ERROR] ${msg}`), 438 + }, 439 + metrics: { 440 + requestStart: (id, url) => console.log(`[METRIC] Start: ${id} → ${url}`), 441 + requestEnd: (id, status, ms) => console.log(`[METRIC] End: ${id} ${status} (${ms}ms)`), 442 + }, 443 + baseUrl: Url("https://jsonplaceholder.typicode.com"), 444 + } 445 + 446 + // Example types 447 + type User = { id: number; name: string; email: string } 448 + type Post = { id: number; title: string; body: string } 449 + 450 + const demo = async () => { 451 + console.log("╔═══════════════════════════════════════════════════════════════╗") 452 + console.log("║ HTTP CLIENT DEMO - purus-ts ║") 453 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 454 + 455 + // 1. Simple GET request 456 + console.log("─── 1. Simple GET request ───") 457 + const getUser = pipe( 458 + get<User>("/users/1"), 459 + provide(mockEnv) 460 + ) 461 + 462 + try { 463 + const user = await runPromise(getUser) 464 + console.log(`Got user: ${user.name} (${user.email})\n`) 465 + } catch (e) { 466 + console.log(`Error: ${handleHttpError(e as HttpError)}\n`) 467 + } 468 + 469 + // 2. Parallel batch request 470 + console.log("─── 2. Parallel batch request ───") 471 + const batchUsers = pipe( 472 + batchGet<User>(["/users/1", "/users/2", "/users/3"]), 473 + provide(mockEnv) 474 + ) 475 + 476 + try { 477 + const users = await runPromise(batchUsers) 478 + console.log(`Got ${users.length} users: ${users.map(u => u.name).join(", ")}\n`) 479 + } catch (e) { 480 + console.log(`Error: ${handleHttpError(e as HttpError)}\n`) 481 + } 482 + 483 + // 3. Request with error handling 484 + console.log("─── 3. Request with error (404) ───") 485 + const notFoundRequest = pipe( 486 + get<User>("/users/99999"), 487 + catchAll(error => succeed({ id: 0, name: "Default User", email: "default@example.com" })), 488 + provide(mockEnv) 489 + ) 490 + 491 + const result = await runPromise(notFoundRequest) 492 + console.log(`Got (with fallback): ${result.name}\n`) 493 + 494 + // 4. Timeout demo 495 + console.log("─── 4. Timeout (will timeout quickly) ───") 496 + const slowRequest = pipe( 497 + get<User>("/users/1", { timeoutMs: 1 }), // 1ms timeout - will fail 498 + catchAll(error => { 499 + console.log(` Caught: ${handleHttpError(error)}`) 500 + return succeed({ id: 0, name: "Timeout Fallback", email: "" }) 501 + }), 502 + provide(mockEnv) 503 + ) 504 + 505 + const timeoutResult = await runPromise(slowRequest) 506 + console.log(` Result: ${timeoutResult.name}\n`) 507 + 508 + console.log("═══════════════════════════════════════════════════════════════") 509 + console.log("Demo complete!") 510 + } 511 + 512 + // Run if this is the main module 513 + demo().catch(console.error)
+593
examples/parallel-scraper.ts
··· 1 + /** 2 + * Parallel Web Scraper with Concurrency Control 3 + * 4 + * Showcases: fork, all, race, timeout, retry, fiber supervision, graceful shutdown 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **True cancellation with AbortController wired automatically** 9 + * - Vanilla: Manual AbortController creation, passing, cleanup 10 + * - Purus: Return cleanup function from `async()`, runtime handles abort 11 + * 12 + * 2. **Concurrency limit without semaphore boilerplate** 13 + * - Vanilla: Complex semaphore implementation, async/await coordination 14 + * - Purus: Fork workers, coordinate with fibers 15 + * 16 + * 3. **Progress is just fiber status inspection** 17 + * - Vanilla: Manual progress tracking, callbacks, event emitters 18 + * - Purus: `fiber.status()` returns current state 19 + * 20 + * 4. **Shutdown interrupts ALL in-flight requests** 21 + * - Vanilla: Track all pending promises, call abort on each, handle races 22 + * - Purus: Interrupt parent fiber → all children automatically interrupted 23 + * 24 + * 5. **Retry with timeout per request** 25 + * - Vanilla: Nested try/catch, setTimeout, Promise.race, manual retry count 26 + * - Purus: `pipe(fetch, timeout(5000), retry(3))` - composable 27 + * 28 + * 6. **Race between mirrors for redundancy** 29 + * - Vanilla: Promise.race doesn't cancel losers 30 + * - Purus: First to succeed wins, losers are interrupted and cleaned up 31 + */ 32 + 33 + import { 34 + // Types 35 + type Eff, 36 + type Exit, 37 + type Fiber, 38 + type FiberStatus, 39 + type Branded, 40 + 41 + // Constructors 42 + succeed, 43 + fail, 44 + async, 45 + sync, 46 + 47 + // Transformations 48 + mapEff, 49 + flatMap, 50 + foldEff, 51 + catchAll, 52 + 53 + // Concurrency 54 + fork, 55 + join, 56 + race, 57 + all, 58 + interruptFiber, 59 + 60 + // Combinators 61 + sleep, 62 + timeout, 63 + retry, 64 + tapEff, 65 + 66 + // Composition 67 + pipe, 68 + match, 69 + brand, 70 + 71 + // Runners 72 + runFiber, 73 + runPromise, 74 + runPromiseExit, 75 + } from "../src/index" 76 + 77 + // ============================================================================= 78 + // DOMAIN TYPES 79 + // ============================================================================= 80 + 81 + type Url = Branded<string, "Url"> 82 + const Url = (s: string): Url => brand(s) 83 + 84 + type ScraperId = Branded<string, "ScraperId"> 85 + const ScraperId = (): ScraperId => brand(`scraper-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 86 + 87 + // ============================================================================= 88 + // SCRAPER ERRORS 89 + // ============================================================================= 90 + 91 + type ScrapeError = 92 + | { readonly _tag: "NetworkError"; readonly url: Url; readonly message: string } 93 + | { readonly _tag: "TimeoutError"; readonly url: Url; readonly ms: number } 94 + | { readonly _tag: "ParseError"; readonly url: Url; readonly message: string } 95 + | { readonly _tag: "RateLimited"; readonly url: Url; readonly retryAfterMs: number } 96 + | { readonly _tag: "Interrupted" } 97 + 98 + const ScrapeError = { 99 + network: (url: Url, message: string): ScrapeError => ({ _tag: "NetworkError", url, message }), 100 + timeout: (url: Url, ms: number): ScrapeError => ({ _tag: "TimeoutError", url, ms }), 101 + parse: (url: Url, message: string): ScrapeError => ({ _tag: "ParseError", url, message }), 102 + rateLimited: (url: Url, retryAfterMs: number): ScrapeError => ({ _tag: "RateLimited", url, retryAfterMs }), 103 + interrupted: (): ScrapeError => ({ _tag: "Interrupted" }), 104 + } 105 + 106 + // ============================================================================= 107 + // CORE SCRAPING PRIMITIVES 108 + // ============================================================================= 109 + 110 + /** 111 + * Fetch with automatic abort on cancellation 112 + * 113 + * Vanilla problem: 114 + * ```ts 115 + * const controller = new AbortController() 116 + * try { 117 + * const response = await fetch(url, { signal: controller.signal }) 118 + * // ... 119 + * } finally { 120 + * // Who calls controller.abort()? When? 121 + * // Easy to leak connections on errors 122 + * } 123 + * ``` 124 + * 125 + * Purus solution: 126 + * The cleanup function returned from `async()` is called automatically 127 + * when the fiber is interrupted. No manual tracking needed! 128 + */ 129 + const fetchWithAbort = (url: Url): Eff<string, ScrapeError, unknown> => 130 + async((resume) => { 131 + const controller = new AbortController() 132 + 133 + fetch(url, { signal: controller.signal }) 134 + .then(async response => { 135 + if (response.status === 429) { 136 + const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10) 137 + resume(Exit.fail(ScrapeError.rateLimited(url, retryAfter * 1000))) 138 + } else if (!response.ok) { 139 + resume(Exit.fail(ScrapeError.network(url, `HTTP ${response.status}`))) 140 + } else { 141 + const text = await response.text() 142 + resume(Exit.succeed(text)) 143 + } 144 + }) 145 + .catch(err => { 146 + if (err.name === "AbortError") { 147 + // Fiber was interrupted - don't resume 148 + return 149 + } 150 + resume(Exit.fail(ScrapeError.network(url, err.message))) 151 + }) 152 + 153 + // Cleanup - called automatically on interrupt! 154 + return () => controller.abort() 155 + }) 156 + 157 + /** 158 + * Scrape a single URL with timeout and retry 159 + */ 160 + const scrapeUrl = ( 161 + url: Url, 162 + options: { timeoutMs?: number; retries?: number } = {} 163 + ): Eff<string, ScrapeError, unknown> => { 164 + const timeoutMs = options.timeoutMs ?? 10000 165 + const retries = options.retries ?? 3 166 + 167 + return pipe( 168 + fetchWithAbort(url), 169 + // Add timeout 170 + timeout(timeoutMs), 171 + flatMap(result => 172 + result === null 173 + ? fail(ScrapeError.timeout(url, timeoutMs)) 174 + : succeed(result) 175 + ), 176 + // Retry on failure (except rate limiting) 177 + foldEff( 178 + error => 179 + error._tag === "RateLimited" 180 + ? fail(error) // Don't retry rate limits 181 + : fail(error), 182 + html => succeed(html) 183 + ), 184 + retry(retries) 185 + ) 186 + } 187 + 188 + // ============================================================================= 189 + // CONCURRENCY PATTERNS 190 + // ============================================================================= 191 + 192 + /** 193 + * Worker pool with concurrency limit 194 + * 195 + * Vanilla problem: 196 + * ```ts 197 + * async function scrapeWithLimit(urls, limit) { 198 + * const results = [] 199 + * const executing = new Set() 200 + * 201 + * for (const url of urls) { 202 + * const promise = fetchUrl(url).then(result => { 203 + * executing.delete(promise) 204 + * return result 205 + * }) 206 + * executing.add(promise) 207 + * 208 + * if (executing.size >= limit) { 209 + * await Promise.race(executing) 210 + * } 211 + * } 212 + * 213 + * return Promise.all(results) 214 + * } 215 + * // Complex, error-prone, hard to cancel 216 + * ``` 217 + * 218 + * Purus solution: 219 + * Fork worker fibers, use a simple queue pattern. 220 + * Interruption propagates to all workers automatically. 221 + */ 222 + type ScrapeResult = { 223 + readonly url: Url 224 + readonly result: { readonly _tag: "Success"; readonly html: string } | { readonly _tag: "Error"; readonly error: ScrapeError } 225 + } 226 + 227 + const scrapeWithConcurrency = ( 228 + urls: readonly Url[], 229 + concurrency: number, 230 + options: { timeoutMs?: number; retries?: number } = {} 231 + ): Eff<readonly ScrapeResult[], never, unknown> => { 232 + // Shared mutable state for the queue 233 + let queue = [...urls] 234 + const results: ScrapeResult[] = [] 235 + 236 + const worker = (workerId: number): Eff<void, never, unknown> => { 237 + const processNext = (): Eff<void, never, unknown> => { 238 + const url = queue.shift() 239 + if (!url) { 240 + return succeed(undefined) 241 + } 242 + 243 + return pipe( 244 + sync(() => console.log(` [Worker ${workerId}] Scraping ${url}`)), 245 + flatMap(() => scrapeUrl(url, options)), 246 + foldEff( 247 + error => { 248 + results.push({ url, result: { _tag: "Error", error } }) 249 + console.log(` [Worker ${workerId}] Failed: ${url} - ${error._tag}`) 250 + return succeed(undefined) 251 + }, 252 + html => { 253 + results.push({ url, result: { _tag: "Success", html } }) 254 + console.log(` [Worker ${workerId}] Success: ${url} (${html.length} chars)`) 255 + return succeed(undefined) 256 + } 257 + ), 258 + flatMap(() => processNext()) 259 + ) as Eff<void, never, unknown> 260 + } 261 + 262 + return processNext() 263 + } 264 + 265 + // Fork `concurrency` workers 266 + const workers = Array.from({ length: concurrency }, (_, i) => 267 + fork(worker(i + 1)) 268 + ) 269 + 270 + return pipe( 271 + // Fork all workers 272 + all(workers), 273 + // Wait for all to complete 274 + flatMap((fibers: readonly Fiber<void, never>[]) => 275 + all(fibers.map(f => join(f))) 276 + ), 277 + // Return results 278 + mapEff(() => results as readonly ScrapeResult[]) 279 + ) as Eff<readonly ScrapeResult[], never, unknown> 280 + } 281 + 282 + // ============================================================================= 283 + // MIRROR RACING - First successful response wins 284 + // ============================================================================= 285 + 286 + /** 287 + * Race multiple mirrors for redundancy 288 + * 289 + * Vanilla problem: 290 + * ```ts 291 + * const result = await Promise.race([ 292 + * fetch(mirror1), 293 + * fetch(mirror2), 294 + * fetch(mirror3), 295 + * ]) 296 + * // The other two fetches continue running! Wasted bandwidth. 297 + * // Need manual AbortController coordination. 298 + * ``` 299 + * 300 + * Purus solution: 301 + * `race()` automatically interrupts the losers. 302 + * Their cleanup functions run, connections are aborted. 303 + */ 304 + const scrapeFromMirrors = ( 305 + mirrors: readonly Url[], 306 + options: { timeoutMs?: number } = {} 307 + ): Eff<string, ScrapeError, unknown> => { 308 + if (mirrors.length === 0) { 309 + return fail(ScrapeError.network(Url(""), "No mirrors provided")) 310 + } 311 + 312 + const requests = mirrors.map(url => 313 + pipe( 314 + scrapeUrl(url, options), 315 + tapEff(() => console.log(` Mirror ${url} won the race!`)) 316 + ) 317 + ) 318 + 319 + // Race all - first success wins, others are cancelled 320 + return requests.reduce((a, b) => race(a, b)) 321 + } 322 + 323 + // ============================================================================= 324 + // PROGRESS TRACKING 325 + // ============================================================================= 326 + 327 + type ScrapeProgress = { 328 + readonly total: number 329 + readonly completed: number 330 + readonly failed: number 331 + readonly inProgress: number 332 + } 333 + 334 + /** 335 + * Scrape with progress tracking 336 + * 337 + * Fiber status is inspectable - no callbacks or events needed! 338 + */ 339 + const scrapeWithProgress = ( 340 + urls: readonly Url[], 341 + concurrency: number, 342 + onProgress: (progress: ScrapeProgress) => void 343 + ): Eff<readonly ScrapeResult[], never, unknown> => { 344 + let completed = 0 345 + let failed = 0 346 + 347 + const reportProgress = () => { 348 + onProgress({ 349 + total: urls.length, 350 + completed, 351 + failed, 352 + inProgress: urls.length - completed - failed, 353 + }) 354 + } 355 + 356 + // Wrap each URL scrape with progress reporting 357 + const scrapeWithReport = (url: Url): Eff<ScrapeResult, never, unknown> => 358 + pipe( 359 + scrapeUrl(url), 360 + foldEff( 361 + error => { 362 + failed++ 363 + reportProgress() 364 + return succeed({ url, result: { _tag: "Error" as const, error } }) 365 + }, 366 + html => { 367 + completed++ 368 + reportProgress() 369 + return succeed({ url, result: { _tag: "Success" as const, html } }) 370 + } 371 + ) 372 + ) as Eff<ScrapeResult, never, unknown> 373 + 374 + // Use worker pool pattern 375 + let queue = [...urls] 376 + const results: ScrapeResult[] = [] 377 + 378 + const worker = (): Eff<void, never, unknown> => { 379 + const processNext = (): Eff<void, never, unknown> => { 380 + const url = queue.shift() 381 + if (!url) return succeed(undefined) 382 + 383 + return pipe( 384 + scrapeWithReport(url), 385 + flatMap(result => { 386 + results.push(result) 387 + return processNext() 388 + }) 389 + ) as Eff<void, never, unknown> 390 + } 391 + return processNext() 392 + } 393 + 394 + const workers = Array.from({ length: concurrency }, () => fork(worker())) 395 + 396 + return pipe( 397 + sync(() => reportProgress()), // Initial progress 398 + flatMap(() => all(workers)), 399 + flatMap((fibers: readonly Fiber<void, never>[]) => 400 + all(fibers.map(f => join(f))) 401 + ), 402 + mapEff(() => results as readonly ScrapeResult[]) 403 + ) as Eff<readonly ScrapeResult[], never, unknown> 404 + } 405 + 406 + // ============================================================================= 407 + // GRACEFUL SHUTDOWN 408 + // ============================================================================= 409 + 410 + /** 411 + * Scraper with graceful shutdown support 412 + * 413 + * Vanilla problem: 414 + * ```ts 415 + * // How to stop all in-flight requests? 416 + * // Need to track every promise, every AbortController 417 + * // Race conditions between shutdown and completion 418 + * ``` 419 + * 420 + * Purus solution: 421 + * Interrupt the main fiber → all child fibers are interrupted 422 + * → all cleanup functions run → connections are aborted 423 + */ 424 + type ScraperHandle = { 425 + readonly id: ScraperId 426 + readonly fiber: Fiber<readonly ScrapeResult[], never> 427 + readonly shutdown: () => Eff<void, never, unknown> 428 + readonly getProgress: () => ScrapeProgress 429 + } 430 + 431 + const createScraper = ( 432 + urls: readonly Url[], 433 + concurrency: number 434 + ): Eff<ScraperHandle, never, unknown> => { 435 + let progress: ScrapeProgress = { 436 + total: urls.length, 437 + completed: 0, 438 + failed: 0, 439 + inProgress: 0, 440 + } 441 + 442 + const id = ScraperId() 443 + 444 + const scrapeEffect = scrapeWithProgress( 445 + urls, 446 + concurrency, 447 + p => { progress = p } 448 + ) 449 + 450 + return pipe( 451 + fork(scrapeEffect), 452 + mapEff(fiber => ({ 453 + id, 454 + fiber, 455 + shutdown: () => { 456 + console.log(` [${id}] Shutting down...`) 457 + return interruptFiber(fiber) 458 + }, 459 + getProgress: () => progress, 460 + })) 461 + ) 462 + } 463 + 464 + // ============================================================================= 465 + // DEMO 466 + // ============================================================================= 467 + 468 + const demo = async () => { 469 + console.log("╔═══════════════════════════════════════════════════════════════╗") 470 + console.log("║ PARALLEL SCRAPER DEMO - purus-ts ║") 471 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 472 + 473 + // 1. Basic parallel scraping 474 + console.log("─── 1. Parallel Scraping with Concurrency ───") 475 + 476 + const urls = [ 477 + Url("https://httpbin.org/delay/1"), 478 + Url("https://httpbin.org/delay/2"), 479 + Url("https://httpbin.org/status/404"), 480 + Url("https://httpbin.org/get"), 481 + Url("https://httpbin.org/html"), 482 + ] 483 + 484 + const parallelResult = await runPromiseExit( 485 + scrapeWithConcurrency(urls.slice(0, 3), 2, { timeoutMs: 5000 }) 486 + ) 487 + 488 + if (parallelResult._tag === "Success") { 489 + const results = parallelResult.value 490 + const successes = results.filter(r => r.result._tag === "Success").length 491 + const failures = results.filter(r => r.result._tag === "Error").length 492 + console.log(` ✓ Completed: ${successes} success, ${failures} failures\n`) 493 + } 494 + 495 + // 2. Mirror racing 496 + console.log("─── 2. Mirror Racing ───") 497 + 498 + const mirrors = [ 499 + Url("https://httpbin.org/delay/3"), 500 + Url("https://httpbin.org/delay/1"), // This should win 501 + Url("https://httpbin.org/delay/2"), 502 + ] 503 + 504 + console.log(" Racing 3 mirrors (delays: 3s, 1s, 2s)...") 505 + const raceStart = Date.now() 506 + 507 + const raceResult = await runPromiseExit( 508 + scrapeFromMirrors(mirrors, { timeoutMs: 10000 }) 509 + ) 510 + 511 + const raceTime = Date.now() - raceStart 512 + if (raceResult._tag === "Success") { 513 + console.log(` ✓ Got response in ${raceTime}ms (fastest mirror won)`) 514 + console.log(` ✓ Other mirrors were automatically cancelled\n`) 515 + } 516 + 517 + // 3. Progress tracking 518 + console.log("─── 3. Progress Tracking ───") 519 + 520 + const progressUrls = [ 521 + Url("https://httpbin.org/get"), 522 + Url("https://httpbin.org/html"), 523 + Url("https://httpbin.org/json"), 524 + ] 525 + 526 + const progressResult = await runPromiseExit( 527 + scrapeWithProgress( 528 + progressUrls, 529 + 2, 530 + progress => { 531 + const pct = Math.round((progress.completed / progress.total) * 100) 532 + console.log(` Progress: ${pct}% (${progress.completed}/${progress.total})`) 533 + } 534 + ) 535 + ) 536 + 537 + if (progressResult._tag === "Success") { 538 + console.log(` ✓ All ${progressResult.value.length} URLs processed\n`) 539 + } 540 + 541 + // 4. Graceful shutdown 542 + console.log("─── 4. Graceful Shutdown ───") 543 + 544 + const slowUrls = [ 545 + Url("https://httpbin.org/delay/5"), 546 + Url("https://httpbin.org/delay/5"), 547 + Url("https://httpbin.org/delay/5"), 548 + Url("https://httpbin.org/delay/5"), 549 + ] 550 + 551 + const scraperHandle = await runPromise(createScraper(slowUrls, 2)) 552 + console.log(` Started scraper ${scraperHandle.id}`) 553 + 554 + // Let it run for 1 second, then shutdown 555 + await new Promise(resolve => setTimeout(resolve, 1000)) 556 + 557 + const progress = scraperHandle.getProgress() 558 + console.log(` Progress before shutdown: ${progress.completed}/${progress.total}`) 559 + 560 + await runPromise(scraperHandle.shutdown()) 561 + console.log(" ✓ Shutdown complete - all in-flight requests cancelled\n") 562 + 563 + // 5. Error handling 564 + console.log("─── 5. Error Handling ───") 565 + 566 + const errorUrl = Url("https://httpbin.org/status/500") 567 + const errorResult = await runPromiseExit( 568 + pipe( 569 + scrapeUrl(errorUrl, { retries: 2, timeoutMs: 5000 }), 570 + catchAll(error => { 571 + console.log(` Caught error: ${match(error)({ 572 + NetworkError: ({ url, message }) => `Network error on ${url}: ${message}`, 573 + TimeoutError: ({ url, ms }) => `Timeout after ${ms}ms on ${url}`, 574 + ParseError: ({ url, message }) => `Parse error on ${url}: ${message}`, 575 + RateLimited: ({ url, retryAfterMs }) => `Rate limited on ${url}, retry after ${retryAfterMs}ms`, 576 + Interrupted: () => `Scrape was interrupted`, 577 + })}`) 578 + return succeed("Fallback content") 579 + }) 580 + ) 581 + ) 582 + 583 + if (errorResult._tag === "Success") { 584 + console.log(` ✓ Error handled gracefully\n`) 585 + } 586 + 587 + console.log("═══════════════════════════════════════════════════════════════") 588 + console.log("Demo complete!") 589 + } 590 + 591 + // Note: This demo requires network access 592 + // For offline testing, mock the fetch function 593 + demo().catch(console.error)
+727
examples/reactive-dom.ts
··· 1 + /** 2 + * Reactive DOM with Cancellation 3 + * 4 + * Showcases: Fiber cancellation, async effects, resource cleanup, typestate 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **Event listeners are automatically cleaned up when fiber is cancelled** 9 + * - Vanilla: `addEventListener` + manual `removeEventListener` tracking 10 + * - Purus: Return cleanup function from `async()` - runtime handles it 11 + * 12 + * 2. **No manual removeEventListener tracking** 13 + * - Vanilla: Easy to leak event listeners, especially in component unmount 14 + * - Purus: Cleanup is automatic and guaranteed 15 + * 16 + * 3. **Typestate prevents submitting invalid forms at compile time** 17 + * - Vanilla: `if (form.isValid) form.submit()` - runtime check 18 + * - Purus: `submit(form: Form<Valid>)` - can't call with invalid form 19 + * 20 + * 4. **Debounce/throttle compose naturally** 21 + * - Vanilla: Separate debounce utility, wrapper functions, manual timing 22 + * - Purus: `pipe(inputEvents, debounce(300))` - just effect composition 23 + * 24 + * 5. **Race between user actions** 25 + * - Vanilla: Complex state management to track which action won 26 + * - Purus: `race(onSubmit, onCancel)` - first to complete wins 27 + * 28 + * 6. **Subscription lifecycle is explicit and safe** 29 + * - Vanilla: Callbacks, cleanup functions, useEffect return values 30 + * - Purus: Fiber interruption propagates through entire subscription tree 31 + */ 32 + 33 + import { 34 + // Types 35 + type Eff, 36 + type Exit, 37 + type Fiber, 38 + type Entity, 39 + type Result, 40 + type Option, 41 + 42 + // Constructors 43 + succeed, 44 + fail, 45 + async, 46 + sync, 47 + entity, 48 + transition, 49 + ok, 50 + err, 51 + some, 52 + none, 53 + 54 + // Transformations 55 + mapEff, 56 + flatMap, 57 + foldEff, 58 + catchAll, 59 + 60 + // Concurrency 61 + fork, 62 + join, 63 + race, 64 + interruptFiber, 65 + 66 + // Combinators 67 + sleep, 68 + timeout, 69 + 70 + // Composition 71 + pipe, 72 + match, 73 + 74 + // Runners 75 + runFiber, 76 + runPromise, 77 + } from "../src/index" 78 + 79 + // ============================================================================= 80 + // DOM EVENT PRIMITIVES - Events as Effects with automatic cleanup 81 + // ============================================================================= 82 + 83 + /** 84 + * Listen to a DOM event, returning cleanup function 85 + * 86 + * Vanilla problem: 87 + * ```ts 88 + * const handler = (e) => { ... } 89 + * element.addEventListener('click', handler) 90 + * // Later... but when? Who calls this? Easy to forget! 91 + * element.removeEventListener('click', handler) 92 + * ``` 93 + * 94 + * Purus solution: 95 + * The cleanup function is returned from `async()` and automatically 96 + * called when the fiber is interrupted. No manual tracking needed! 97 + */ 98 + const fromEvent = <E extends Event>( 99 + target: EventTarget, 100 + eventType: string 101 + ): Eff<E, never, unknown> => 102 + async((resume) => { 103 + const handler = (event: Event) => resume(Exit.succeed(event as E)) 104 + target.addEventListener(eventType, handler) 105 + 106 + // Cleanup - called automatically on fiber interrupt! 107 + return () => target.removeEventListener(eventType, handler) 108 + }) 109 + 110 + /** 111 + * Wait for next click on an element 112 + */ 113 + const onClick = (element: EventTarget): Eff<MouseEvent, never, unknown> => 114 + fromEvent<MouseEvent>(element, "click") 115 + 116 + /** 117 + * Wait for next input event 118 + */ 119 + const onInput = (element: EventTarget): Eff<InputEvent, never, unknown> => 120 + fromEvent<InputEvent>(element, "input") 121 + 122 + /** 123 + * Wait for next keydown 124 + */ 125 + const onKeyDown = (element: EventTarget): Eff<KeyboardEvent, never, unknown> => 126 + fromEvent<KeyboardEvent>(element, "keydown") 127 + 128 + /** 129 + * Wait for form submission 130 + */ 131 + const onSubmit = (form: EventTarget): Eff<SubmitEvent, never, unknown> => 132 + fromEvent<SubmitEvent>(form, "submit") 133 + 134 + // ============================================================================= 135 + // EFFECT COMBINATORS FOR DOM - Debounce, Throttle, etc. 136 + // ============================================================================= 137 + 138 + /** 139 + * Debounce an effect - wait for quiet period before emitting 140 + * 141 + * Vanilla problem: 142 + * ```ts 143 + * let timeoutId: number | null = null 144 + * input.addEventListener('input', (e) => { 145 + * if (timeoutId) clearTimeout(timeoutId) 146 + * timeoutId = setTimeout(() => { 147 + * // Actually handle the event 148 + * }, 300) 149 + * }) 150 + * // And don't forget cleanup on unmount! 151 + * ``` 152 + * 153 + * Purus solution: 154 + * Debouncing is just racing against a sleep timer! 155 + * First event to arrive after the quiet period wins. 156 + */ 157 + const debounce = <A, E, R>(ms: number) => 158 + (effect: Eff<A, E, R>): Eff<A, E, R> => 159 + pipe( 160 + sleep(ms), 161 + flatMap(() => effect) 162 + ) as Eff<A, E, R> 163 + 164 + /** 165 + * Throttle - emit at most once per time window 166 + */ 167 + const throttle = <A, E, R>(ms: number) => 168 + (effect: Eff<A, E, R>): Eff<A, E, R> => 169 + pipe( 170 + effect, 171 + flatMap(value => 172 + pipe( 173 + sleep(ms), 174 + mapEff(() => value) 175 + ) 176 + ) 177 + ) as Eff<A, E, R> 178 + 179 + /** 180 + * Take first N events then stop 181 + */ 182 + const take = (n: number) => 183 + <A, E, R>(getEvent: () => Eff<A, E, R>): Eff<readonly A[], E, R> => { 184 + const loop = (remaining: number, acc: readonly A[]): Eff<readonly A[], E, R> => 185 + remaining <= 0 186 + ? succeed(acc) 187 + : pipe( 188 + getEvent(), 189 + flatMap(a => loop(remaining - 1, [...acc, a])) 190 + ) as Eff<readonly A[], E, R> 191 + 192 + return loop(n, []) 193 + } 194 + 195 + /** 196 + * Repeat while predicate is true 197 + */ 198 + const takeWhile = <A, E, R>( 199 + predicate: (a: A) => boolean 200 + ) => 201 + (getEvent: () => Eff<A, E, R>): Eff<readonly A[], E, R> => { 202 + const loop = (acc: readonly A[]): Eff<readonly A[], E, R> => 203 + pipe( 204 + getEvent(), 205 + flatMap(a => 206 + predicate(a) 207 + ? loop([...acc, a]) 208 + : succeed(acc) 209 + ) 210 + ) as Eff<readonly A[], E, R> 211 + 212 + return loop([]) 213 + } 214 + 215 + // ============================================================================= 216 + // FORM TYPESTATE - Valid forms can't be submitted, invalid forms can't be processed 217 + // ============================================================================= 218 + 219 + /** 220 + * Form state machine via phantom types 221 + * 222 + * Vanilla problem: 223 + * ```ts 224 + * const form = { 225 + * isValid: boolean, 226 + * values: {...}, 227 + * errors: {...}, 228 + * } 229 + * 230 + * function submit(form: Form) { 231 + * if (!form.isValid) throw new Error("...") // Runtime check! 232 + * // submit... 233 + * } 234 + * ``` 235 + * 236 + * Purus solution: 237 + * ```ts 238 + * function submit(form: Form<Valid>) { 239 + * // Can ONLY be called with a valid form - enforced at compile time! 240 + * } 241 + * 242 + * // Compile error: Type 'Form<Invalid>' is not assignable to 'Form<Valid>' 243 + * submit(invalidForm) 244 + * ``` 245 + */ 246 + 247 + type Empty = "Empty" 248 + type Invalid = "Invalid" 249 + type Valid = "Valid" 250 + type Submitting = "Submitting" 251 + type Submitted = "Submitted" 252 + 253 + type FormData<T> = { 254 + readonly values: T 255 + readonly errors: Partial<Record<keyof T, string>> 256 + readonly touched: Partial<Record<keyof T, boolean>> 257 + } 258 + 259 + type EmptyForm<T> = Entity<FormData<T>, Empty> 260 + type InvalidForm<T> = Entity<FormData<T>, Invalid> 261 + type ValidForm<T> = Entity<FormData<T>, Valid> 262 + type SubmittingForm<T> = Entity<FormData<T>, Submitting> 263 + type SubmittedForm<T> = Entity<FormData<T>, Submitted> 264 + 265 + // Form state constructors 266 + const emptyForm = <T>(initial: T): EmptyForm<T> => 267 + entity({ values: initial, errors: {}, touched: {} }) 268 + 269 + // Form state transitions 270 + const toInvalid = <T, _S extends Empty | Valid>() => 271 + transition<FormData<T>, _S, Invalid>() 272 + 273 + const toValid = <T, _S extends Empty | Invalid>() => 274 + transition<FormData<T>, _S, Valid>() 275 + 276 + const toSubmitting = <T>() => 277 + transition<FormData<T>, Valid, Submitting>() 278 + 279 + const toSubmitted = <T>() => 280 + transition<FormData<T>, Submitting, Submitted>() 281 + 282 + /** 283 + * Validate form and transition to Valid or Invalid state 284 + */ 285 + type ValidationRule<T, K extends keyof T> = (value: T[K]) => Option<string> 286 + 287 + const validate = <T>( 288 + form: EmptyForm<T> | InvalidForm<T> | ValidForm<T>, 289 + rules: Partial<{ [K in keyof T]: ValidationRule<T, K> }> 290 + ): ValidForm<T> | InvalidForm<T> => { 291 + const errors: Partial<Record<keyof T, string>> = {} 292 + let hasErrors = false 293 + 294 + for (const key of Object.keys(rules) as Array<keyof T>) { 295 + const rule = rules[key] as ValidationRule<T, typeof key> 296 + const result = rule(form.values[key]) 297 + if (result._tag === "Some") { 298 + errors[key] = result.value 299 + hasErrors = true 300 + } 301 + } 302 + 303 + const newForm = { 304 + ...form, 305 + errors, 306 + touched: Object.keys(form.values as object).reduce( 307 + (acc, key) => ({ ...acc, [key]: true }), 308 + {} as Partial<Record<keyof T, boolean>> 309 + ), 310 + } 311 + 312 + return hasErrors 313 + ? entity<FormData<T>, Invalid>(newForm) 314 + : entity<FormData<T>, Valid>(newForm) 315 + } 316 + 317 + // ============================================================================= 318 + // INTERACTIVE FORM EXAMPLE 319 + // ============================================================================= 320 + 321 + type LoginFormValues = { 322 + readonly email: string 323 + readonly password: string 324 + } 325 + 326 + type LoginError = 327 + | { readonly _tag: "ValidationFailed"; readonly errors: Partial<Record<keyof LoginFormValues, string>> } 328 + | { readonly _tag: "SubmissionFailed"; readonly message: string } 329 + 330 + const LoginError = { 331 + validationFailed: (errors: Partial<Record<keyof LoginFormValues, string>>): LoginError => 332 + ({ _tag: "ValidationFailed", errors }), 333 + submissionFailed: (message: string): LoginError => 334 + ({ _tag: "SubmissionFailed", message }), 335 + } 336 + 337 + /** 338 + * Email validation rule 339 + */ 340 + const emailRule: ValidationRule<LoginFormValues, "email"> = (email) => 341 + email.includes("@") ? none : some("Invalid email address") 342 + 343 + /** 344 + * Password validation rule 345 + */ 346 + const passwordRule: ValidationRule<LoginFormValues, "password"> = (password) => 347 + password.length >= 8 ? none : some("Password must be at least 8 characters") 348 + 349 + /** 350 + * Create a login form controller 351 + * 352 + * This demonstrates how purus makes form handling cleaner: 353 + * 1. Typestate ensures you can't submit invalid forms 354 + * 2. Event cleanup is automatic when form is abandoned 355 + * 3. Race between submit/cancel is trivial 356 + */ 357 + const createLoginForm = ( 358 + emailInput: HTMLInputElement, 359 + passwordInput: HTMLInputElement, 360 + submitButton: HTMLButtonElement, 361 + cancelButton: HTMLButtonElement 362 + ): Eff<LoginFormValues, LoginError, unknown> => { 363 + // Initial form state 364 + const initialForm = emptyForm<LoginFormValues>({ 365 + email: "", 366 + password: "", 367 + }) 368 + 369 + /** 370 + * Watch for input changes 371 + * 372 + * Vanilla: Multiple event listeners, manual state management 373 + * Purus: Fork fibers for each input, collect updates 374 + */ 375 + const watchInputs = ( 376 + form: EmptyForm<LoginFormValues> | InvalidForm<LoginFormValues> 377 + ): Eff<ValidForm<LoginFormValues>, LoginError, unknown> => 378 + pipe( 379 + // Race: submit button click OR cancel 380 + race( 381 + // Wait for submit click 382 + pipe( 383 + onClick(submitButton), 384 + mapEff(() => { 385 + // Read current values from DOM 386 + const values: LoginFormValues = { 387 + email: emailInput.value, 388 + password: passwordInput.value, 389 + } 390 + // Update form with current values 391 + const updatedForm = entity<FormData<LoginFormValues>, Empty | Invalid>({ 392 + ...form, 393 + values, 394 + }) as EmptyForm<LoginFormValues> 395 + 396 + // Validate 397 + return validate(updatedForm, { 398 + email: emailRule, 399 + password: passwordRule, 400 + }) 401 + }) 402 + ), 403 + 404 + // Cancel button ends the form 405 + pipe( 406 + onClick(cancelButton), 407 + flatMap(() => fail(LoginError.submissionFailed("Cancelled by user"))) 408 + ) 409 + ), 410 + 411 + // Handle validation result 412 + flatMap(validatedForm => { 413 + if (validatedForm.errors && Object.keys(validatedForm.errors).length > 0) { 414 + // Show errors and try again 415 + console.log(" Validation failed:", validatedForm.errors) 416 + return watchInputs(validatedForm as InvalidForm<LoginFormValues>) 417 + } 418 + return succeed(validatedForm as ValidForm<LoginFormValues>) 419 + }) 420 + ) 421 + 422 + /** 423 + * Submit the form 424 + */ 425 + const submitForm = (form: ValidForm<LoginFormValues>): Eff<LoginFormValues, LoginError, unknown> => 426 + pipe( 427 + sync(() => console.log(" Submitting form...")), 428 + flatMap(() => sleep(1000)), // Simulate API call 429 + flatMap(() => { 430 + // Simulate success 431 + console.log(" Form submitted successfully!") 432 + return succeed(form.values) 433 + }) 434 + ) 435 + 436 + // Full workflow 437 + return pipe( 438 + watchInputs(initialForm), 439 + flatMap(validForm => submitForm(validForm)) 440 + ) 441 + } 442 + 443 + // ============================================================================= 444 + // SUBSCRIPTION PATTERNS 445 + // ============================================================================= 446 + 447 + /** 448 + * Subscribe to continuous events until cancelled 449 + * 450 + * This pattern is impossible to get right in vanilla JS without leaks. 451 + * With purus, cancellation propagates automatically. 452 + */ 453 + const subscribe = <A, E, R>( 454 + getEvent: () => Eff<A, E, R>, 455 + onEvent: (a: A) => void 456 + ): Eff<never, E, R> => { 457 + const loop = (): Eff<never, E, R> => 458 + pipe( 459 + getEvent(), 460 + flatMap(a => { 461 + onEvent(a) 462 + return loop() 463 + }) 464 + ) as Eff<never, E, R> 465 + 466 + return loop() 467 + } 468 + 469 + /** 470 + * Mouse position tracker with automatic cleanup 471 + * 472 + * Vanilla problem: 473 + * ```ts 474 + * const handler = (e) => { position = { x: e.clientX, y: e.clientY } } 475 + * document.addEventListener('mousemove', handler) 476 + * // When to remove? How to coordinate with component lifecycle? 477 + * ``` 478 + * 479 + * Purus solution: 480 + * Fork a fiber, interrupt it when done. Cleanup is automatic. 481 + */ 482 + type Position = { readonly x: number; readonly y: number } 483 + 484 + const trackMousePosition = ( 485 + target: EventTarget, 486 + onMove: (pos: Position) => void 487 + ): Eff<Fiber<never, never>, never, unknown> => 488 + fork( 489 + subscribe( 490 + () => fromEvent<MouseEvent>(target, "mousemove"), 491 + (e) => onMove({ x: e.clientX, y: e.clientY }) 492 + ) 493 + ) 494 + 495 + /** 496 + * Debounced search input 497 + * 498 + * Fires search after user stops typing for 300ms 499 + */ 500 + const debouncedSearch = ( 501 + input: HTMLInputElement, 502 + onSearch: (query: string) => void 503 + ): Eff<Fiber<never, never>, never, unknown> => 504 + fork( 505 + subscribe( 506 + () => 507 + pipe( 508 + onInput(input), 509 + debounce(300) 510 + ), 511 + (e) => onSearch((e.target as HTMLInputElement).value) 512 + ) 513 + ) 514 + 515 + // ============================================================================= 516 + // KEYBOARD SHORTCUTS WITH RACE 517 + // ============================================================================= 518 + 519 + /** 520 + * Wait for a specific key combination 521 + */ 522 + const waitForKey = ( 523 + target: EventTarget, 524 + key: string, 525 + modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean } = {} 526 + ): Eff<KeyboardEvent, never, unknown> => 527 + pipe( 528 + onKeyDown(target), 529 + flatMap(e => { 530 + const matches = 531 + e.key === key && 532 + (!modifiers.ctrl || e.ctrlKey) && 533 + (!modifiers.alt || e.altKey) && 534 + (!modifiers.shift || e.shiftKey) 535 + 536 + return matches 537 + ? succeed(e) 538 + : waitForKey(target, key, modifiers) 539 + }) 540 + ) 541 + 542 + /** 543 + * Race between multiple keyboard shortcuts 544 + * 545 + * First shortcut to be pressed wins, others are cleaned up. 546 + */ 547 + const waitForShortcut = ( 548 + target: EventTarget, 549 + shortcuts: ReadonlyArray<{ key: string; ctrl?: boolean; alt?: boolean; name: string }> 550 + ): Eff<string, never, unknown> => { 551 + if (shortcuts.length === 0) { 552 + return pipe( 553 + onKeyDown(target), 554 + flatMap(() => waitForShortcut(target, shortcuts)) 555 + ) 556 + } 557 + 558 + const waiters = shortcuts.map(s => 559 + pipe( 560 + waitForKey(target, s.key, { ctrl: s.ctrl, alt: s.alt }), 561 + mapEff(() => s.name) 562 + ) 563 + ) 564 + 565 + return waiters.reduce((a, b) => race(a, b)) 566 + } 567 + 568 + // ============================================================================= 569 + // DEMO - Simulated DOM environment 570 + // ============================================================================= 571 + 572 + /** 573 + * Mock DOM elements for Node.js environment 574 + * In a browser, you'd use real DOM elements 575 + */ 576 + const createMockElement = (name: string): EventTarget => { 577 + const listeners = new Map<string, Set<EventListener>>() 578 + 579 + return { 580 + addEventListener(type: string, listener: EventListener) { 581 + if (!listeners.has(type)) listeners.set(type, new Set()) 582 + listeners.get(type)!.add(listener) 583 + console.log(` [${name}] Added listener for '${type}'`) 584 + }, 585 + removeEventListener(type: string, listener: EventListener) { 586 + listeners.get(type)?.delete(listener) 587 + console.log(` [${name}] Removed listener for '${type}'`) 588 + }, 589 + dispatchEvent(event: Event): boolean { 590 + listeners.get(event.type)?.forEach(l => l(event)) 591 + return true 592 + }, 593 + } 594 + } 595 + 596 + const demo = async () => { 597 + console.log("╔═══════════════════════════════════════════════════════════════╗") 598 + console.log("║ REACTIVE DOM DEMO - purus-ts ║") 599 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 600 + 601 + // 1. Event subscription with automatic cleanup 602 + console.log("─── 1. Event Subscription with Cleanup ───") 603 + const button = createMockElement("Button") 604 + 605 + const clickFiber = runFiber( 606 + pipe( 607 + fork( 608 + subscribe( 609 + () => onClick(button), 610 + () => console.log(" Button clicked!") 611 + ) 612 + ), 613 + flatMap(fiber => 614 + pipe( 615 + sleep(100), 616 + flatMap(() => { 617 + console.log(" Interrupting subscription...") 618 + return interruptFiber(fiber) 619 + }) 620 + ) 621 + ) 622 + ), 623 + undefined 624 + ) 625 + 626 + // Simulate some clicks 627 + setTimeout(() => button.dispatchEvent(new MouseEvent("click")), 20) 628 + setTimeout(() => button.dispatchEvent(new MouseEvent("click")), 50) 629 + 630 + await clickFiber.await() 631 + console.log(" ✓ Subscription interrupted, listeners cleaned up\n") 632 + 633 + // 2. Debounced input 634 + console.log("─── 2. Debounced Input ───") 635 + const input = createMockElement("SearchInput") 636 + 637 + let searchCount = 0 638 + const searchFiber = runFiber( 639 + debouncedSearch(input as HTMLInputElement, query => { 640 + searchCount++ 641 + console.log(` Search #${searchCount}: "${query}"`) 642 + }), 643 + undefined 644 + ) 645 + 646 + // Simulate rapid typing - only last one should fire 647 + const simulateTyping = (delay: number, value: string) => { 648 + setTimeout(() => { 649 + const event = { target: { value } } as unknown as InputEvent 650 + input.dispatchEvent(Object.assign(new Event("input"), event)) 651 + }, delay) 652 + } 653 + 654 + simulateTyping(10, "h") 655 + simulateTyping(20, "he") 656 + simulateTyping(30, "hel") 657 + simulateTyping(40, "hello") // Only this should trigger search after debounce 658 + 659 + await sleep(500).then(() => runPromise(succeed(undefined))) 660 + 661 + // Stop the search subscription 662 + const fiber = await runPromise(searchFiber.join().then(f => f) as unknown as Eff<Fiber<never, never>, never, unknown>) 663 + await runPromise(interruptFiber(fiber as unknown as Fiber<never, never>)) 664 + console.log(" ✓ Debounced search completed\n") 665 + 666 + // 3. Race between actions 667 + console.log("─── 3. Race Between Actions ───") 668 + const saveBtn = createMockElement("SaveButton") 669 + const cancelBtn = createMockElement("CancelButton") 670 + 671 + const raceEffect = race( 672 + pipe( 673 + onClick(saveBtn), 674 + mapEff(() => "saved" as const) 675 + ), 676 + pipe( 677 + onClick(cancelBtn), 678 + mapEff(() => "cancelled" as const) 679 + ) 680 + ) 681 + 682 + const raceFiber = runFiber(raceEffect, undefined) 683 + 684 + // Simulate cancel being clicked first 685 + setTimeout(() => { 686 + cancelBtn.dispatchEvent(new MouseEvent("click")) 687 + console.log(" Cancel clicked first!") 688 + }, 50) 689 + 690 + const raceResult = await raceFiber.await() 691 + if (raceResult._tag === "Success") { 692 + console.log(` ✓ Race result: ${raceResult.value}`) 693 + console.log(" ✓ Other listener was automatically cleaned up\n") 694 + } 695 + 696 + // 4. Typestate form validation 697 + console.log("─── 4. Typestate Form Validation ───") 698 + 699 + const form = emptyForm<LoginFormValues>({ email: "", password: "" }) 700 + console.log(" Created empty form") 701 + 702 + // Update with invalid values 703 + const invalidAttempt = validate( 704 + { ...form, values: { email: "notanemail", password: "short" } } as EmptyForm<LoginFormValues>, 705 + { email: emailRule, password: passwordRule } 706 + ) 707 + console.log(" Validation result:", invalidAttempt.errors) 708 + 709 + // Update with valid values 710 + const validForm = validate( 711 + { ...form, values: { email: "user@example.com", password: "securepassword123" } } as EmptyForm<LoginFormValues>, 712 + { email: emailRule, password: passwordRule } 713 + ) 714 + console.log(" Valid form:", Object.keys(validForm.errors || {}).length === 0 ? "✓" : "✗") 715 + 716 + // Demonstrate typestate - this would be a compile error: 717 + // toSubmitting<LoginFormValues>()(invalidAttempt) // Error: Type 'InvalidForm' not assignable to 'ValidForm' 718 + 719 + const submitting = toSubmitting<LoginFormValues>()(validForm as ValidForm<LoginFormValues>) 720 + const submitted = toSubmitted<LoginFormValues>()(submitting) 721 + console.log(" Form state transitions: Empty → Valid → Submitting → Submitted ✓\n") 722 + 723 + console.log("═══════════════════════════════════════════════════════════════") 724 + console.log("Demo complete!") 725 + } 726 + 727 + demo().catch(console.error)
+574
examples/task-queue.ts
··· 1 + /** 2 + * Background Job Queue with Fiber Supervision 3 + * 4 + * Showcases: Fork/join, fiber supervision, retry, timeout, dependency injection, graceful shutdown 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **Workers are fibers - supervision is built-in** 9 + * - Vanilla: Manual worker management, restart logic, crash handling 10 + * - Purus: Fork workers, join to wait, interrupt to stop - all composable 11 + * 12 + * 2. **Timeout/retry per job without boilerplate** 13 + * - Vanilla: Each job needs its own try/catch, timeout handling, retry counter 14 + * - Purus: `pipe(job, timeout(30000), retry(3))` - one line 15 + * 16 + * 3. **Shutdown interrupts cleanly** 17 + * - Vanilla: Track all jobs, signal each, wait for completion, handle races 18 + * - Purus: Interrupt the worker pool → all jobs interrupted → cleanup runs 19 + * 20 + * 4. **Typed job payloads with exhaustive handling** 21 + * - Vanilla: Union types with `switch`, easy to forget cases 22 + * - Purus: `match()` on job type → compiler enforces all cases handled 23 + * 24 + * 5. **Dead letter queue is just effect composition** 25 + * - Vanilla: Separate error handling logic, manual DLQ push 26 + * - Purus: `catchAll(error => pushToDeadLetter(job, error))` 27 + * 28 + * 6. **Dependency injection for testing** 29 + * - Vanilla: Global state or constructor injection 30 + * - Purus: `provide(mockServices)(queue)` - swap implementations trivially 31 + */ 32 + 33 + import { 34 + // Types 35 + type Eff, 36 + type Exit, 37 + type Fiber, 38 + type FiberStatus, 39 + type Branded, 40 + type Result, 41 + 42 + // Constructors 43 + succeed, 44 + fail, 45 + async, 46 + sync, 47 + ok, 48 + err, 49 + 50 + // Transformations 51 + mapEff, 52 + flatMap, 53 + foldEff, 54 + catchAll, 55 + access, 56 + accessEff, 57 + provide, 58 + 59 + // Concurrency 60 + fork, 61 + join, 62 + race, 63 + all, 64 + interruptFiber, 65 + 66 + // Combinators 67 + sleep, 68 + timeout, 69 + retry, 70 + tapEff, 71 + 72 + // Composition 73 + pipe, 74 + match, 75 + brand, 76 + 77 + // Runners 78 + runFiber, 79 + runPromise, 80 + runPromiseExit, 81 + } from "../src/index" 82 + 83 + // ============================================================================= 84 + // DOMAIN TYPES 85 + // ============================================================================= 86 + 87 + type JobId = Branded<string, "JobId"> 88 + const JobId = (): JobId => brand(`job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) 89 + 90 + type QueueId = Branded<string, "QueueId"> 91 + const QueueId = (): QueueId => brand(`queue-${Date.now()}`) 92 + 93 + // ============================================================================= 94 + // JOB DEFINITIONS - Discriminated union of all job types 95 + // ============================================================================= 96 + 97 + /** 98 + * Typed job payloads 99 + * 100 + * Each job type has a specific payload and processing logic. 101 + * Pattern matching ensures all job types are handled. 102 + */ 103 + type Job = 104 + | { readonly _tag: "SendEmail"; readonly id: JobId; readonly to: string; readonly subject: string; readonly body: string } 105 + | { readonly _tag: "ProcessImage"; readonly id: JobId; readonly imageUrl: string; readonly operations: readonly string[] } 106 + | { readonly _tag: "GenerateReport"; readonly id: JobId; readonly reportType: string; readonly params: Record<string, unknown> } 107 + | { readonly _tag: "SyncData"; readonly id: JobId; readonly source: string; readonly destination: string } 108 + | { readonly _tag: "Cleanup"; readonly id: JobId; readonly olderThanDays: number } 109 + 110 + const Job = { 111 + sendEmail: (to: string, subject: string, body: string): Job => 112 + ({ _tag: "SendEmail", id: JobId(), to, subject, body }), 113 + 114 + processImage: (imageUrl: string, operations: readonly string[]): Job => 115 + ({ _tag: "ProcessImage", id: JobId(), imageUrl, operations }), 116 + 117 + generateReport: (reportType: string, params: Record<string, unknown>): Job => 118 + ({ _tag: "GenerateReport", id: JobId(), reportType, params }), 119 + 120 + syncData: (source: string, destination: string): Job => 121 + ({ _tag: "SyncData", id: JobId(), source, destination }), 122 + 123 + cleanup: (olderThanDays: number): Job => 124 + ({ _tag: "Cleanup", id: JobId(), olderThanDays }), 125 + } 126 + 127 + // ============================================================================= 128 + // JOB ERRORS 129 + // ============================================================================= 130 + 131 + type JobError = 132 + | { readonly _tag: "ProcessingFailed"; readonly jobId: JobId; readonly reason: string } 133 + | { readonly _tag: "Timeout"; readonly jobId: JobId; readonly timeoutMs: number } 134 + | { readonly _tag: "MaxRetriesExceeded"; readonly jobId: JobId; readonly attempts: number } 135 + | { readonly _tag: "ValidationFailed"; readonly jobId: JobId; readonly message: string } 136 + 137 + const JobError = { 138 + processingFailed: (jobId: JobId, reason: string): JobError => 139 + ({ _tag: "ProcessingFailed", jobId, reason }), 140 + timeout: (jobId: JobId, timeoutMs: number): JobError => 141 + ({ _tag: "Timeout", jobId, timeoutMs }), 142 + maxRetries: (jobId: JobId, attempts: number): JobError => 143 + ({ _tag: "MaxRetriesExceeded", jobId, attempts }), 144 + validation: (jobId: JobId, message: string): JobError => 145 + ({ _tag: "ValidationFailed", jobId, message }), 146 + } 147 + 148 + // ============================================================================= 149 + // SERVICE INTERFACES 150 + // ============================================================================= 151 + 152 + type EmailService = { 153 + readonly send: (to: string, subject: string, body: string) => Eff<void, string, unknown> 154 + } 155 + 156 + type ImageService = { 157 + readonly process: (url: string, operations: readonly string[]) => Eff<string, string, unknown> 158 + } 159 + 160 + type ReportService = { 161 + readonly generate: (type: string, params: Record<string, unknown>) => Eff<string, string, unknown> 162 + } 163 + 164 + type DataService = { 165 + readonly sync: (source: string, destination: string) => Eff<number, string, unknown> 166 + } 167 + 168 + type CleanupService = { 169 + readonly deleteOld: (olderThanDays: number) => Eff<number, string, unknown> 170 + } 171 + 172 + type DeadLetterQueue = { 173 + readonly push: (job: Job, error: JobError) => Eff<void, never, unknown> 174 + readonly getAll: () => Eff<readonly { job: Job; error: JobError }[], never, unknown> 175 + } 176 + 177 + type Services = { 178 + readonly email: EmailService 179 + readonly image: ImageService 180 + readonly report: ReportService 181 + readonly data: DataService 182 + readonly cleanup: CleanupService 183 + readonly deadLetter: DeadLetterQueue 184 + } 185 + 186 + // ============================================================================= 187 + // JOB PROCESSOR - Pattern matching on job types 188 + // ============================================================================= 189 + 190 + /** 191 + * Process a single job 192 + * 193 + * This is where typed pattern matching shines - the compiler 194 + * ensures we handle ALL job types. Add a new job type and 195 + * the code won't compile until we add a handler. 196 + */ 197 + const processJob = (job: Job): Eff<void, JobError, Services> => 198 + accessEff<Services, void, JobError>(services => { 199 + const process = match(job)({ 200 + SendEmail: ({ id, to, subject, body }) => 201 + pipe( 202 + sync(() => console.log(` [${id}] Sending email to ${to}`)), 203 + flatMap(() => services.email.send(to, subject, body)), 204 + mapEff(() => console.log(` [${id}] Email sent`)), 205 + catchAll(reason => fail(JobError.processingFailed(id, reason))) 206 + ), 207 + 208 + ProcessImage: ({ id, imageUrl, operations }) => 209 + pipe( 210 + sync(() => console.log(` [${id}] Processing image: ${imageUrl}`)), 211 + flatMap(() => services.image.process(imageUrl, operations)), 212 + mapEff(result => console.log(` [${id}] Image processed: ${result}`)), 213 + catchAll(reason => fail(JobError.processingFailed(id, reason))) 214 + ), 215 + 216 + GenerateReport: ({ id, reportType, params }) => 217 + pipe( 218 + sync(() => console.log(` [${id}] Generating ${reportType} report`)), 219 + flatMap(() => services.report.generate(reportType, params)), 220 + mapEff(result => console.log(` [${id}] Report generated: ${result}`)), 221 + catchAll(reason => fail(JobError.processingFailed(id, reason))) 222 + ), 223 + 224 + SyncData: ({ id, source, destination }) => 225 + pipe( 226 + sync(() => console.log(` [${id}] Syncing ${source} → ${destination}`)), 227 + flatMap(() => services.data.sync(source, destination)), 228 + mapEff(count => console.log(` [${id}] Synced ${count} records`)), 229 + catchAll(reason => fail(JobError.processingFailed(id, reason))) 230 + ), 231 + 232 + Cleanup: ({ id, olderThanDays }) => 233 + pipe( 234 + sync(() => console.log(` [${id}] Cleaning up data older than ${olderThanDays} days`)), 235 + flatMap(() => services.cleanup.deleteOld(olderThanDays)), 236 + mapEff(count => console.log(` [${id}] Cleaned up ${count} records`)), 237 + catchAll(reason => fail(JobError.processingFailed(id, reason))) 238 + ), 239 + }) 240 + 241 + return process as Eff<void, JobError, unknown> 242 + }) 243 + 244 + /** 245 + * Process job with timeout and retry 246 + */ 247 + const processJobWithRetry = ( 248 + job: Job, 249 + options: { timeoutMs?: number; maxRetries?: number } = {} 250 + ): Eff<void, JobError, Services> => { 251 + const timeoutMs = options.timeoutMs ?? 30000 252 + const maxRetries = options.maxRetries ?? 3 253 + 254 + return pipe( 255 + processJob(job), 256 + timeout(timeoutMs), 257 + flatMap(result => 258 + result === null 259 + ? fail(JobError.timeout(job.id, timeoutMs)) 260 + : succeed(result) 261 + ), 262 + retry(maxRetries), 263 + catchAll(error => { 264 + // After all retries failed, push to dead letter queue 265 + return accessEff<Services, void, JobError>(({ deadLetter }) => 266 + pipe( 267 + deadLetter.push(job, error), 268 + flatMap(() => fail(error)) 269 + ) 270 + ) 271 + }) 272 + ) 273 + } 274 + 275 + // ============================================================================= 276 + // WORKER POOL 277 + // ============================================================================= 278 + 279 + type WorkerPool = { 280 + readonly id: QueueId 281 + readonly submit: (job: Job) => Eff<void, never, unknown> 282 + readonly getStats: () => WorkerStats 283 + readonly shutdown: () => Eff<void, never, unknown> 284 + } 285 + 286 + type WorkerStats = { 287 + readonly queued: number 288 + readonly processing: number 289 + readonly completed: number 290 + readonly failed: number 291 + } 292 + 293 + /** 294 + * Create a worker pool with configurable concurrency 295 + * 296 + * Vanilla problem: 297 + * ```ts 298 + * class WorkerPool { 299 + * private workers: Worker[] = [] 300 + * private queue: Job[] = [] 301 + * private processing = 0 302 + * 303 + * async submit(job: Job) { 304 + * this.queue.push(job) 305 + * await this.processNext() 306 + * } 307 + * 308 + * async shutdown() { 309 + * // Complex - need to: 310 + * // 1. Stop accepting new jobs 311 + * // 2. Wait for in-flight jobs 312 + * // 3. Cancel remaining queued jobs 313 + * // 4. Handle race conditions 314 + * } 315 + * } 316 + * ``` 317 + * 318 + * Purus solution: 319 + * Workers are fibers. Submit adds to queue. 320 + * Shutdown interrupts all workers → cleanup runs automatically. 321 + */ 322 + const createWorkerPool = ( 323 + workerCount: number, 324 + services: Services 325 + ): Eff<WorkerPool, never, unknown> => { 326 + const id = QueueId() 327 + const queue: Job[] = [] 328 + const stats = { queued: 0, processing: 0, completed: 0, failed: 0 } 329 + let running = true 330 + const workers: Fiber<void, never>[] = [] 331 + 332 + const worker = (workerId: number): Eff<void, never, unknown> => { 333 + const processNext = (): Eff<void, never, unknown> => { 334 + if (!running) { 335 + return succeed(undefined) 336 + } 337 + 338 + const job = queue.shift() 339 + if (!job) { 340 + // No jobs, wait and check again 341 + return pipe( 342 + sleep(100), 343 + flatMap(() => processNext()) 344 + ) 345 + } 346 + 347 + stats.queued-- 348 + stats.processing++ 349 + console.log(` [Worker ${workerId}] Processing ${job._tag} (${job.id})`) 350 + 351 + return pipe( 352 + processJobWithRetry(job), 353 + provide(services), 354 + foldEff( 355 + error => { 356 + stats.processing-- 357 + stats.failed++ 358 + console.log(` [Worker ${workerId}] Failed: ${job.id} - ${error._tag}`) 359 + return succeed(undefined) 360 + }, 361 + () => { 362 + stats.processing-- 363 + stats.completed++ 364 + console.log(` [Worker ${workerId}] Completed: ${job.id}`) 365 + return succeed(undefined) 366 + } 367 + ), 368 + flatMap(() => processNext()) 369 + ) as Eff<void, never, unknown> 370 + } 371 + 372 + return processNext() 373 + } 374 + 375 + // Fork all workers 376 + const startWorkers = (): Eff<void, never, unknown> => 377 + pipe( 378 + all(Array.from({ length: workerCount }, (_, i) => fork(worker(i + 1)))), 379 + mapEff(fibers => { 380 + workers.push(...fibers) 381 + }) 382 + ) as Eff<void, never, unknown> 383 + 384 + return pipe( 385 + startWorkers(), 386 + mapEff(() => ({ 387 + id, 388 + 389 + submit: (job: Job): Eff<void, never, unknown> => 390 + sync(() => { 391 + queue.push(job) 392 + stats.queued++ 393 + console.log(` [Queue] Job submitted: ${job._tag} (${job.id})`) 394 + }), 395 + 396 + getStats: () => ({ ...stats }), 397 + 398 + shutdown: (): Eff<void, never, unknown> => { 399 + running = false 400 + console.log(` [Queue ${id}] Shutting down ${workers.length} workers...`) 401 + 402 + return pipe( 403 + all(workers.map(w => interruptFiber(w))), 404 + mapEff(() => { 405 + console.log(` [Queue ${id}] All workers stopped`) 406 + }) 407 + ) as Eff<void, never, unknown> 408 + }, 409 + })) 410 + ) 411 + } 412 + 413 + // ============================================================================= 414 + // MOCK SERVICES 415 + // ============================================================================= 416 + 417 + const createMockServices = (): Services => { 418 + const deadLetterItems: { job: Job; error: JobError }[] = [] 419 + 420 + return { 421 + email: { 422 + send: (to, subject, _body) => 423 + pipe( 424 + sleep(500), // Simulate network delay 425 + flatMap(() => 426 + Math.random() > 0.1 427 + ? succeed(undefined) 428 + : fail(`Failed to send email to ${to}`) 429 + ) 430 + ), 431 + }, 432 + 433 + image: { 434 + process: (url, operations) => 435 + pipe( 436 + sleep(800), 437 + mapEff(() => `processed-${operations.join("-")}-${url.split("/").pop()}`) 438 + ), 439 + }, 440 + 441 + report: { 442 + generate: (type, params) => 443 + pipe( 444 + sleep(1000), 445 + mapEff(() => `report-${type}-${Date.now()}.pdf`) 446 + ), 447 + }, 448 + 449 + data: { 450 + sync: (source, destination) => 451 + pipe( 452 + sleep(600), 453 + mapEff(() => Math.floor(Math.random() * 1000)) 454 + ), 455 + }, 456 + 457 + cleanup: { 458 + deleteOld: (days) => 459 + pipe( 460 + sleep(400), 461 + mapEff(() => Math.floor(Math.random() * 100)) 462 + ), 463 + }, 464 + 465 + deadLetter: { 466 + push: (job, error) => 467 + sync(() => { 468 + deadLetterItems.push({ job, error }) 469 + console.log(` [DLQ] Job ${job.id} added: ${error._tag}`) 470 + }), 471 + 472 + getAll: () => 473 + succeed(deadLetterItems), 474 + }, 475 + } 476 + } 477 + 478 + // ============================================================================= 479 + // DEMO 480 + // ============================================================================= 481 + 482 + const demo = async () => { 483 + console.log("╔═══════════════════════════════════════════════════════════════╗") 484 + console.log("║ TASK QUEUE DEMO - purus-ts ║") 485 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 486 + 487 + const services = createMockServices() 488 + 489 + // 1. Create worker pool 490 + console.log("─── 1. Create Worker Pool ───") 491 + const pool = await runPromise(createWorkerPool(3, services)) 492 + console.log(` ✓ Created worker pool ${pool.id} with 3 workers\n`) 493 + 494 + // 2. Submit jobs 495 + console.log("─── 2. Submit Jobs ───") 496 + 497 + const jobs = [ 498 + Job.sendEmail("user@example.com", "Welcome!", "Hello and welcome..."), 499 + Job.processImage("https://example.com/photo.jpg", ["resize", "watermark"]), 500 + Job.generateReport("monthly", { month: 12, year: 2024 }), 501 + Job.syncData("postgres", "elasticsearch"), 502 + Job.cleanup(30), 503 + Job.sendEmail("admin@example.com", "Alert", "System notification"), 504 + ] 505 + 506 + for (const job of jobs) { 507 + await runPromise(pool.submit(job)) 508 + } 509 + 510 + console.log(` ✓ Submitted ${jobs.length} jobs\n`) 511 + 512 + // 3. Monitor progress 513 + console.log("─── 3. Processing... ───") 514 + 515 + // Wait for jobs to complete 516 + await new Promise(resolve => setTimeout(resolve, 4000)) 517 + 518 + const stats = pool.getStats() 519 + console.log(`\n Stats: queued=${stats.queued}, processing=${stats.processing}, completed=${stats.completed}, failed=${stats.failed}\n`) 520 + 521 + // 4. Check dead letter queue 522 + console.log("─── 4. Dead Letter Queue ───") 523 + const dlqItems = await runPromise(services.deadLetter.getAll()) 524 + if (dlqItems.length > 0) { 525 + console.log(` ${dlqItems.length} failed jobs in DLQ:`) 526 + dlqItems.forEach(({ job, error }) => { 527 + console.log(` - ${job._tag} (${job.id}): ${error._tag}`) 528 + }) 529 + } else { 530 + console.log(" ✓ No failed jobs") 531 + } 532 + console.log() 533 + 534 + // 5. Graceful shutdown 535 + console.log("─── 5. Graceful Shutdown ───") 536 + 537 + // Submit more jobs 538 + await runPromise(pool.submit(Job.processImage("https://example.com/big.jpg", ["optimize"]))) 539 + await runPromise(pool.submit(Job.generateReport("annual", { year: 2024 }))) 540 + 541 + // Shutdown immediately 542 + await runPromise(pool.shutdown()) 543 + 544 + const finalStats = pool.getStats() 545 + console.log(` Final stats: queued=${finalStats.queued}, completed=${finalStats.completed}, failed=${finalStats.failed}\n`) 546 + 547 + // 6. Error handling demo 548 + console.log("─── 6. Error Handling ───") 549 + 550 + const formatJobError = (error: JobError): string => 551 + match(error)({ 552 + ProcessingFailed: ({ jobId, reason }) => `Job ${jobId} failed: ${reason}`, 553 + Timeout: ({ jobId, timeoutMs }) => `Job ${jobId} timed out after ${timeoutMs}ms`, 554 + MaxRetriesExceeded: ({ jobId, attempts }) => `Job ${jobId} failed after ${attempts} attempts`, 555 + ValidationFailed: ({ jobId, message }) => `Job ${jobId} validation failed: ${message}`, 556 + }) 557 + 558 + const errorJob = Job.sendEmail("invalid", "Test", "Body") 559 + const errorResult = await runPromiseExit( 560 + pipe( 561 + processJobWithRetry(errorJob, { timeoutMs: 100, maxRetries: 1 }), 562 + provide(services) 563 + ) 564 + ) 565 + 566 + if (errorResult._tag === "Failure") { 567 + console.log(` ${formatJobError(errorResult.error as JobError)}\n`) 568 + } 569 + 570 + console.log("═══════════════════════════════════════════════════════════════") 571 + console.log("Demo complete!") 572 + } 573 + 574 + demo().catch(console.error)
+707
examples/workflow-engine.ts
··· 1 + /** 2 + * Order Processing Pipeline 3 + * 4 + * Showcases: Branded types, refinements, typestate, Result, dependency injection, pattern matching 5 + * 6 + * ## Why purus is better than vanilla JS/TS: 7 + * 8 + * 1. **Branded IDs prevent mixing up different entities** 9 + * - Vanilla: `function processOrder(userId: string, orderId: string)` - easy to swap args! 10 + * - Purus: `function processOrder(userId: UserId, orderId: OrderId)` - compiler catches swaps 11 + * 12 + * 2. **Refinements guarantee validated values at compile time** 13 + * - Vanilla: `if (quantity > 0)` checks scattered everywhere, easy to forget 14 + * - Purus: `PositiveQuantity` type - can't construct invalid values 15 + * 16 + * 3. **Typestate prevents invalid state transitions** 17 + * - Vanilla: Runtime checks like `if (order.status === 'paid') order.ship()` 18 + * - Purus: `ship(order: Order<Paid>)` - can't ship unpaid orders, compiler enforces 19 + * 20 + * 4. **Result type makes errors explicit and composable** 21 + * - Vanilla: Exceptions fly anywhere, `try/catch` scattered, error types unknown 22 + * - Purus: `Result<Order, OrderError>` - caller knows exactly what can fail 23 + * 24 + * 5. **Pattern matching forces exhaustive error handling** 25 + * - Vanilla: `switch` can forget cases, no compiler warning 26 + * - Purus: `match()` errors if you miss a case, add new error → compiler tells you where 27 + * 28 + * 6. **Dependency injection is just function composition** 29 + * - Vanilla: DI frameworks, decorators, reflection, constructor injection 30 + * - Purus: `provide(services)(effect)` - swap implementations trivially for tests 31 + */ 32 + 33 + import { 34 + // Types 35 + type Branded, 36 + type Refined, 37 + type Entity, 38 + type Option, 39 + type Result, 40 + type Eff, 41 + 42 + // Constructors 43 + brand, 44 + refine, 45 + entity, 46 + transition, 47 + some, 48 + none, 49 + ok, 50 + err, 51 + succeed, 52 + fail, 53 + sync, 54 + 55 + // Transformations 56 + mapResult, 57 + chainResult, 58 + mapOption, 59 + flatMapOption, 60 + getOrElse, 61 + mapEff, 62 + flatMap, 63 + catchAll, 64 + accessEff, 65 + provide, 66 + 67 + // Pattern matching 68 + match, 69 + matchResult, 70 + matchOption, 71 + 72 + // Composition 73 + pipe, 74 + 75 + // Runners 76 + runPromise, 77 + } from "../src/index" 78 + 79 + // ============================================================================= 80 + // BRANDED TYPES - Distinct IDs that can't be mixed up 81 + // ============================================================================= 82 + 83 + /** 84 + * The Problem with Plain Strings 85 + * 86 + * Vanilla TypeScript: 87 + * ```ts 88 + * function placeOrder(userId: string, productId: string, orderId: string) { ... } 89 + * 90 + * // This compiles! But it's wrong - arguments are swapped 91 + * placeOrder(orderId, userId, productId) 92 + * ``` 93 + * 94 + * With Branded Types: 95 + * ```ts 96 + * function placeOrder(userId: UserId, productId: ProductId, orderId: OrderId) { ... } 97 + * 98 + * // Compile error! Type 'OrderId' is not assignable to type 'UserId' 99 + * placeOrder(orderId, userId, productId) 100 + * ``` 101 + */ 102 + 103 + type UserId = Branded<string, "UserId"> 104 + const UserId = { 105 + create: (): UserId => brand(`user-${crypto.randomUUID().slice(0, 8)}`), 106 + fromString: (s: string): UserId => brand(s), 107 + } 108 + 109 + type ProductId = Branded<string, "ProductId"> 110 + const ProductId = { 111 + create: (): ProductId => brand(`prod-${crypto.randomUUID().slice(0, 8)}`), 112 + fromString: (s: string): ProductId => brand(s), 113 + } 114 + 115 + type OrderId = Branded<string, "OrderId"> 116 + const OrderId = { 117 + create: (): OrderId => brand(`order-${crypto.randomUUID().slice(0, 8)}`), 118 + fromString: (s: string): OrderId => brand(s), 119 + } 120 + 121 + // ============================================================================= 122 + // REFINEMENT TYPES - Values with proven properties 123 + // ============================================================================= 124 + 125 + /** 126 + * The Problem with Unchecked Numbers 127 + * 128 + * Vanilla TypeScript: 129 + * ```ts 130 + * function addToCart(productId: string, quantity: number) { 131 + * // Need to check quantity > 0 here... 132 + * // And here... 133 + * // And everywhere quantity is used... 134 + * } 135 + * ``` 136 + * 137 + * With Refinements: 138 + * ```ts 139 + * function addToCart(productId: ProductId, quantity: PositiveInt) { 140 + * // quantity is GUARANTEED to be positive by the type system 141 + * // No runtime check needed - it was validated at construction 142 + * } 143 + * ``` 144 + */ 145 + 146 + type PositiveInt = Refined<number, "PositiveInt"> 147 + const positiveInt = refine<number, "PositiveInt">(n => Number.isInteger(n) && n > 0) 148 + 149 + type NonNegativeAmount = Refined<number, "NonNegativeAmount"> 150 + const nonNegativeAmount = refine<number, "NonNegativeAmount">(n => n >= 0) 151 + 152 + type Email = Branded<string, "Email"> 153 + const Email = { 154 + create: (s: string): Option<Email> => 155 + s.includes("@") ? some(brand(s)) : none, 156 + unsafeCreate: (s: string): Email => brand(s), 157 + } 158 + 159 + // ============================================================================= 160 + // ORDER STATE MACHINE - Typestate pattern 161 + // ============================================================================= 162 + 163 + /** 164 + * The Problem with String Status Fields 165 + * 166 + * Vanilla TypeScript: 167 + * ```ts 168 + * type Order = { 169 + * status: "draft" | "pending" | "paid" | "shipped" | "delivered" 170 + * ... 171 + * } 172 + * 173 + * function shipOrder(order: Order) { 174 + * if (order.status !== "paid") { 175 + * throw new Error("Can't ship unpaid order") // Runtime error! 176 + * } 177 + * order.status = "shipped" 178 + * } 179 + * ``` 180 + * 181 + * With Typestate: 182 + * ```ts 183 + * type PaidOrder = Entity<OrderData, "Paid"> 184 + * type ShippedOrder = Entity<OrderData, "Shipped"> 185 + * 186 + * function shipOrder(order: PaidOrder): ShippedOrder { 187 + * // Can ONLY be called with a PaidOrder - enforced at compile time! 188 + * return transition<OrderData, "Paid", "Shipped">()(order) 189 + * } 190 + * 191 + * // Compile error: Argument of type 'DraftOrder' is not assignable 192 + * shipOrder(draftOrder) 193 + * ``` 194 + */ 195 + 196 + type OrderData = { 197 + readonly id: OrderId 198 + readonly userId: UserId 199 + readonly items: ReadonlyArray<OrderItem> 200 + readonly total: NonNegativeAmount 201 + readonly shippingAddress?: Address 202 + readonly paymentId?: string 203 + readonly trackingNumber?: string 204 + } 205 + 206 + type OrderItem = { 207 + readonly productId: ProductId 208 + readonly name: string 209 + readonly quantity: PositiveInt 210 + readonly price: NonNegativeAmount 211 + } 212 + 213 + type Address = { 214 + readonly street: string 215 + readonly city: string 216 + readonly zip: string 217 + readonly country: string 218 + } 219 + 220 + // Order states as phantom types 221 + type Draft = "Draft" 222 + type Pending = "Pending" 223 + type Paid = "Paid" 224 + type Shipped = "Shipped" 225 + type Delivered = "Delivered" 226 + type Cancelled = "Cancelled" 227 + 228 + type DraftOrder = Entity<OrderData, Draft> 229 + type PendingOrder = Entity<OrderData, Pending> 230 + type PaidOrder = Entity<OrderData, Paid> 231 + type ShippedOrder = Entity<OrderData, Shipped> 232 + type DeliveredOrder = Entity<OrderData, Delivered> 233 + type CancelledOrder = Entity<OrderData, Cancelled> 234 + 235 + // State transitions - each is a pure function with precise types 236 + const toDraft = (data: OrderData): DraftOrder => entity(data) 237 + const toPending = transition<OrderData, Draft, Pending>() 238 + const toPaid = (paymentId: string) => 239 + transition<OrderData, Pending, Paid>(order => ({ ...order, paymentId })) 240 + const toShipped = (trackingNumber: string) => 241 + transition<OrderData, Paid, Shipped>(order => ({ ...order, trackingNumber })) 242 + const toDelivered = transition<OrderData, Shipped, Delivered>() 243 + const toCancelled = <S extends Draft | Pending>() => 244 + transition<OrderData, S, Cancelled>() 245 + 246 + // ============================================================================= 247 + // TYPED BUSINESS ERRORS 248 + // ============================================================================= 249 + 250 + /** 251 + * The Problem with Generic Errors 252 + * 253 + * Vanilla TypeScript: 254 + * try { await placeOrder(...) } 255 + * catch (e) { // What is e? Could be ANYTHING } 256 + * 257 + * With Discriminated Union Errors: 258 + * const result: Result<Order, OrderError> = placeOrder(...) 259 + * match(result)({ 260 + * Ok: ({ value }) => handleSuccess(value), 261 + * Err: ({ error }) => match(error)({ 262 + * OutOfStock: ... , // Must handle 263 + * PaymentDeclined: ..., // Must handle 264 + * // Compiler error if you miss one! 265 + * }) 266 + * }) 267 + */ 268 + 269 + type OrderError = 270 + | { readonly _tag: "EmptyCart" } 271 + | { readonly _tag: "OutOfStock"; readonly productId: ProductId; readonly available: number } 272 + | { readonly _tag: "PaymentDeclined"; readonly reason: string } 273 + | { readonly _tag: "InvalidAddress"; readonly message: string } 274 + | { readonly _tag: "UserNotFound"; readonly userId: UserId } 275 + | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId } 276 + | { readonly _tag: "InvalidStateTransition"; readonly from: string; readonly to: string } 277 + 278 + const OrderError = { 279 + emptyCart: (): OrderError => ({ _tag: "EmptyCart" }), 280 + outOfStock: (productId: ProductId, available: number): OrderError => 281 + ({ _tag: "OutOfStock", productId, available }), 282 + paymentDeclined: (reason: string): OrderError => 283 + ({ _tag: "PaymentDeclined", reason }), 284 + invalidAddress: (message: string): OrderError => 285 + ({ _tag: "InvalidAddress", message }), 286 + userNotFound: (userId: UserId): OrderError => 287 + ({ _tag: "UserNotFound", userId }), 288 + orderNotFound: (orderId: OrderId): OrderError => 289 + ({ _tag: "OrderNotFound", orderId }), 290 + invalidTransition: (from: string, to: string): OrderError => 291 + ({ _tag: "InvalidStateTransition", from, to }), 292 + } 293 + 294 + // ============================================================================= 295 + // SERVICE INTERFACES - For dependency injection 296 + // ============================================================================= 297 + 298 + type User = { 299 + readonly id: UserId 300 + readonly email: Email 301 + readonly name: string 302 + } 303 + 304 + type Product = { 305 + readonly id: ProductId 306 + readonly name: string 307 + readonly price: NonNegativeAmount 308 + readonly stock: number 309 + } 310 + 311 + type UserService = { 312 + readonly getUser: (id: UserId) => Eff<User, OrderError, unknown> 313 + } 314 + 315 + type InventoryService = { 316 + readonly getProduct: (id: ProductId) => Eff<Product, OrderError, unknown> 317 + readonly reserveStock: (productId: ProductId, quantity: PositiveInt) => Eff<void, OrderError, unknown> 318 + readonly releaseStock: (productId: ProductId, quantity: PositiveInt) => Eff<void, never, unknown> 319 + } 320 + 321 + type PaymentService = { 322 + readonly processPayment: (userId: UserId, amount: NonNegativeAmount) => Eff<string, OrderError, unknown> 323 + readonly refundPayment: (paymentId: string) => Eff<void, never, unknown> 324 + } 325 + 326 + type ShippingService = { 327 + readonly validateAddress: (address: Address) => Eff<void, OrderError, unknown> 328 + readonly createShipment: (orderId: OrderId, address: Address) => Eff<string, OrderError, unknown> 329 + } 330 + 331 + type NotificationService = { 332 + readonly sendOrderConfirmation: (email: Email, orderId: OrderId) => Eff<void, never, unknown> 333 + readonly sendShippingNotification: (email: Email, trackingNumber: string) => Eff<void, never, unknown> 334 + } 335 + 336 + type Services = { 337 + readonly users: UserService 338 + readonly inventory: InventoryService 339 + readonly payments: PaymentService 340 + readonly shipping: ShippingService 341 + readonly notifications: NotificationService 342 + } 343 + 344 + // ============================================================================= 345 + // ORDER WORKFLOW - Pure functional business logic 346 + // ============================================================================= 347 + 348 + /** 349 + * Create a new draft order 350 + * 351 + * Note: This validates that items is non-empty at the type level! 352 + * If items were empty, we couldn't construct the DraftOrder. 353 + */ 354 + const createOrder = ( 355 + userId: UserId, 356 + items: ReadonlyArray<{ productId: ProductId; name: string; quantity: PositiveInt; price: NonNegativeAmount }> 357 + ): Result<DraftOrder, OrderError> => { 358 + if (items.length === 0) { 359 + return err(OrderError.emptyCart()) 360 + } 361 + 362 + const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0) 363 + 364 + return ok(toDraft({ 365 + id: OrderId.create(), 366 + userId, 367 + items: items as ReadonlyArray<OrderItem>, 368 + total: nonNegativeAmount(total) ?? (0 as NonNegativeAmount), 369 + })) 370 + } 371 + 372 + /** 373 + * Submit order for processing 374 + * 375 + * This is where Purus shines - the entire workflow is: 376 + * 1. Type-safe (can't mix up IDs, quantities are validated) 377 + * 2. Error-tracked (all failures are typed and must be handled) 378 + * 3. Dependency-injected (services are passed via environment) 379 + * 4. Composable (each step is a pure function) 380 + */ 381 + const submitOrder = ( 382 + order: DraftOrder, 383 + shippingAddress: Address 384 + ): Eff<PendingOrder, OrderError, Services> => 385 + accessEff<Services, PendingOrder, OrderError>(({ inventory, shipping }) => 386 + pipe( 387 + // Validate shipping address 388 + shipping.validateAddress(shippingAddress), 389 + 390 + // Reserve inventory for all items 391 + flatMap(() => 392 + order.items.reduce( 393 + (acc, item) => 394 + pipe( 395 + acc, 396 + flatMap(() => inventory.reserveStock(item.productId, item.quantity)) 397 + ), 398 + succeed(undefined) as Eff<void, OrderError, unknown> 399 + ) 400 + ), 401 + 402 + // Transition to pending state 403 + mapEff(() => { 404 + const withAddress = { ...order, shippingAddress } 405 + return toPending(withAddress as unknown as DraftOrder) 406 + }) 407 + ) 408 + ) 409 + 410 + /** 411 + * Process payment for a pending order 412 + */ 413 + const processPayment = (order: PendingOrder): Eff<PaidOrder, OrderError, Services> => 414 + accessEff<Services, PaidOrder, OrderError>(({ users, payments, notifications }) => 415 + pipe( 416 + // Get user for notification 417 + users.getUser(order.userId), 418 + 419 + // Process payment 420 + flatMap(user => 421 + pipe( 422 + payments.processPayment(order.userId, order.total), 423 + flatMap(paymentId => 424 + pipe( 425 + // Send confirmation email 426 + notifications.sendOrderConfirmation(user.email, order.id), 427 + // Transition to paid state 428 + mapEff(() => toPaid(paymentId)(order)) 429 + ) 430 + ) 431 + ) 432 + ) 433 + ) 434 + ) 435 + 436 + /** 437 + * Ship a paid order 438 + */ 439 + const shipOrder = (order: PaidOrder): Eff<ShippedOrder, OrderError, Services> => 440 + accessEff<Services, ShippedOrder, OrderError>(({ users, shipping, notifications }) => 441 + pipe( 442 + users.getUser(order.userId), 443 + flatMap(user => 444 + pipe( 445 + shipping.createShipment(order.id, order.shippingAddress!), 446 + flatMap(trackingNumber => 447 + pipe( 448 + notifications.sendShippingNotification(user.email, trackingNumber), 449 + mapEff(() => toShipped(trackingNumber)(order)) 450 + ) 451 + ) 452 + ) 453 + ) 454 + ) 455 + ) 456 + 457 + /** 458 + * Full order workflow - from cart to shipped 459 + * 460 + * Look how clean this is compared to the vanilla equivalent with 461 + * try/catch, if/else, and scattered validation! 462 + */ 463 + const placeOrder = ( 464 + userId: UserId, 465 + items: ReadonlyArray<{ productId: ProductId; name: string; quantity: PositiveInt; price: NonNegativeAmount }>, 466 + shippingAddress: Address 467 + ): Eff<ShippedOrder, OrderError, Services> => 468 + pipe( 469 + // Create draft order (synchronous, returns Result) 470 + sync(() => createOrder(userId, items)), 471 + 472 + // Unwrap Result to Eff 473 + flatMap(result => 474 + matchResult<DraftOrder, OrderError, Eff<DraftOrder, OrderError, unknown>>( 475 + order => succeed(order), 476 + error => fail(error) 477 + )(result) 478 + ), 479 + 480 + // Submit → Pay → Ship 481 + flatMap(draft => submitOrder(draft, shippingAddress)), 482 + flatMap(pending => processPayment(pending)), 483 + flatMap(paid => shipOrder(paid)) 484 + ) 485 + 486 + // ============================================================================= 487 + // ERROR HANDLING WITH EXHAUSTIVE PATTERN MATCHING 488 + // ============================================================================= 489 + 490 + /** 491 + * Format error for display 492 + * 493 + * If you add a new OrderError variant, the compiler will error here 494 + * until you add a handler for it. No forgotten edge cases! 495 + */ 496 + const formatOrderError = (error: OrderError): string => 497 + match(error)({ 498 + EmptyCart: () => "Cannot create an order with an empty cart", 499 + OutOfStock: ({ productId, available }) => 500 + `Product ${productId} is out of stock (only ${available} available)`, 501 + PaymentDeclined: ({ reason }) => `Payment was declined: ${reason}`, 502 + InvalidAddress: ({ message }) => `Invalid shipping address: ${message}`, 503 + UserNotFound: ({ userId }) => `User ${userId} not found`, 504 + OrderNotFound: ({ orderId }) => `Order ${orderId} not found`, 505 + InvalidStateTransition: ({ from, to }) => 506 + `Cannot transition order from ${from} to ${to}`, 507 + }) 508 + 509 + // ============================================================================= 510 + // DEMO - Mock services and run the workflow 511 + // ============================================================================= 512 + 513 + const createMockServices = (): Services => { 514 + // In-memory "database" 515 + const users = new Map<string, User>([ 516 + [ 517 + "user-1", 518 + { 519 + id: UserId.fromString("user-1"), 520 + email: Email.unsafeCreate("alice@example.com"), 521 + name: "Alice", 522 + }, 523 + ], 524 + ]) 525 + 526 + const products = new Map<string, Product>([ 527 + [ 528 + "prod-1", 529 + { 530 + id: ProductId.fromString("prod-1"), 531 + name: "Widget", 532 + price: nonNegativeAmount(29.99) ?? (0 as NonNegativeAmount), 533 + stock: 100, 534 + }, 535 + ], 536 + [ 537 + "prod-2", 538 + { 539 + id: ProductId.fromString("prod-2"), 540 + name: "Gadget", 541 + price: nonNegativeAmount(49.99) ?? (0 as NonNegativeAmount), 542 + stock: 5, 543 + }, 544 + ], 545 + ]) 546 + 547 + return { 548 + users: { 549 + getUser: (id) => { 550 + const user = users.get(id) 551 + return user ? succeed(user) : fail(OrderError.userNotFound(id)) 552 + }, 553 + }, 554 + 555 + inventory: { 556 + getProduct: (id) => { 557 + const product = products.get(id) 558 + return product 559 + ? succeed(product) 560 + : fail(OrderError.outOfStock(id, 0)) 561 + }, 562 + reserveStock: (productId, quantity) => { 563 + const product = products.get(productId) 564 + if (!product) return fail(OrderError.outOfStock(productId, 0)) 565 + if (product.stock < quantity) { 566 + return fail(OrderError.outOfStock(productId, product.stock)) 567 + } 568 + products.set(productId, { ...product, stock: product.stock - quantity }) 569 + console.log(` [Inventory] Reserved ${quantity}x ${product.name}`) 570 + return succeed(undefined) 571 + }, 572 + releaseStock: (productId, quantity) => { 573 + const product = products.get(productId) 574 + if (product) { 575 + products.set(productId, { ...product, stock: product.stock + quantity }) 576 + } 577 + return succeed(undefined) 578 + }, 579 + }, 580 + 581 + payments: { 582 + processPayment: (userId, amount) => { 583 + console.log(` [Payment] Processing $${amount} for ${userId}`) 584 + return succeed(`pay-${Date.now()}`) 585 + }, 586 + refundPayment: (paymentId) => { 587 + console.log(` [Payment] Refunded ${paymentId}`) 588 + return succeed(undefined) 589 + }, 590 + }, 591 + 592 + shipping: { 593 + validateAddress: (address) => { 594 + if (!address.zip || address.zip.length < 3) { 595 + return fail(OrderError.invalidAddress("Invalid ZIP code")) 596 + } 597 + console.log(` [Shipping] Address validated: ${address.city}`) 598 + return succeed(undefined) 599 + }, 600 + createShipment: (orderId, address) => { 601 + const tracking = `TRACK-${Date.now()}` 602 + console.log(` [Shipping] Created shipment ${tracking} for ${orderId}`) 603 + return succeed(tracking) 604 + }, 605 + }, 606 + 607 + notifications: { 608 + sendOrderConfirmation: (email, orderId) => { 609 + console.log(` [Email] Order confirmation sent to ${email}`) 610 + return succeed(undefined) 611 + }, 612 + sendShippingNotification: (email, trackingNumber) => { 613 + console.log(` [Email] Shipping notification sent: ${trackingNumber}`) 614 + return succeed(undefined) 615 + }, 616 + }, 617 + } 618 + } 619 + 620 + const demo = async () => { 621 + console.log("╔═══════════════════════════════════════════════════════════════╗") 622 + console.log("║ ORDER WORKFLOW DEMO - purus-ts ║") 623 + console.log("╚═══════════════════════════════════════════════════════════════╝\n") 624 + 625 + const services = createMockServices() 626 + 627 + // Test 1: Successful order 628 + console.log("─── 1. Successful Order Flow ───") 629 + const userId = UserId.fromString("user-1") 630 + const items = [ 631 + { 632 + productId: ProductId.fromString("prod-1"), 633 + name: "Widget", 634 + quantity: positiveInt(2) as PositiveInt, 635 + price: nonNegativeAmount(29.99) as NonNegativeAmount, 636 + }, 637 + ] 638 + const address: Address = { 639 + street: "123 Main St", 640 + city: "San Francisco", 641 + zip: "94105", 642 + country: "USA", 643 + } 644 + 645 + const successfulOrder = pipe( 646 + placeOrder(userId, items, address), 647 + provide(services) 648 + ) 649 + 650 + try { 651 + const shipped = await runPromise(successfulOrder) 652 + console.log(`\n ✓ Order ${shipped.id} shipped with tracking ${shipped.trackingNumber}\n`) 653 + } catch (e) { 654 + console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 655 + } 656 + 657 + // Test 2: Empty cart error 658 + console.log("─── 2. Empty Cart Error ───") 659 + const emptyOrder = pipe( 660 + placeOrder(userId, [], address), 661 + provide(services) 662 + ) 663 + 664 + try { 665 + await runPromise(emptyOrder) 666 + } catch (e) { 667 + console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 668 + } 669 + 670 + // Test 3: Invalid address error 671 + console.log("─── 3. Invalid Address Error ───") 672 + const badAddress: Address = { street: "", city: "", zip: "", country: "" } 673 + const badAddressOrder = pipe( 674 + placeOrder(userId, items, badAddress), 675 + provide(services) 676 + ) 677 + 678 + try { 679 + await runPromise(badAddressOrder) 680 + } catch (e) { 681 + console.log(` ✗ ${formatOrderError(e as OrderError)}\n`) 682 + } 683 + 684 + // Test 4: Error recovery with fallback 685 + console.log("─── 4. Error Recovery ───") 686 + const orderWithRecovery = pipe( 687 + placeOrder(userId, items, badAddress), 688 + catchAll(error => { 689 + console.log(` Caught error: ${formatOrderError(error)}`) 690 + console.log(` Retrying with valid address...`) 691 + return placeOrder(userId, items, address) 692 + }), 693 + provide(services) 694 + ) 695 + 696 + try { 697 + const recovered = await runPromise(orderWithRecovery) 698 + console.log(` ✓ Recovered! Order ${recovered.id} shipped\n`) 699 + } catch (e) { 700 + console.log(` ✗ Recovery failed: ${formatOrderError(e as OrderError)}\n`) 701 + } 702 + 703 + console.log("═══════════════════════════════════════════════════════════════") 704 + console.log("Demo complete!") 705 + } 706 + 707 + demo().catch(console.error)
+40
package.json
··· 1 + { 2 + "name": "purus-ts", 3 + "version": "0.1.0", 4 + "description": "Pure TypeScript effect system with fiber-based concurrency, brands, refinements, and pattern matching", 5 + "type": "module", 6 + "main": "./dist/index.js", 7 + "types": "./dist/index.d.ts", 8 + "exports": { 9 + ".": { 10 + "types": "./dist/index.d.ts", 11 + "import": "./dist/index.js" 12 + } 13 + }, 14 + "files": [ 15 + "dist" 16 + ], 17 + "scripts": { 18 + "build": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly", 19 + "type-check": "tsc --noEmit", 20 + "test": "bun test", 21 + "prepublishOnly": "bun run type-check && bun run build" 22 + }, 23 + "keywords": [ 24 + "functional-programming", 25 + "effect-system", 26 + "fiber", 27 + "concurrency", 28 + "type-safety", 29 + "brands", 30 + "refinements", 31 + "pattern-matching", 32 + "result", 33 + "option" 34 + ], 35 + "license": "MIT", 36 + "devDependencies": { 37 + "@types/bun": "latest", 38 + "typescript": "^5" 39 + } 40 + }
+975
src/index.ts
··· 1 + /** 2 + * ┌─────────────────────────────────────────────────────────────────────────┐ 3 + * │ │ 4 + * │ purus.ts — Pure TypeScript │ 5 + * │ │ 6 + * │ "purus" is Latin for "pure" │ 7 + * │ │ 8 + * │ A minimal, educational library for: │ 9 + * │ • Contract-first development (refinements, brands) │ 10 + * │ • Pure functional programming (Result, Option, pattern matching) │ 11 + * │ • Effect system with typed errors and dependencies │ 12 + * │ • Fiber-based concurrency (cancellation, racing, parallelism) │ 13 + * │ │ 14 + * │ No classes. No early returns. Everything is an expression. │ 15 + * │ │ 16 + * └─────────────────────────────────────────────────────────────────────────┘ 17 + */ 18 + 19 + // ============================================================================= 20 + // PART I: FOUNDATIONS 21 + // ============================================================================= 22 + 23 + // ───────────────────────────────────────────────────────────────────────────── 24 + // Brands — Distinct types from primitives 25 + // ───────────────────────────────────────────────────────────────────────────── 26 + 27 + declare const __brand: unique symbol 28 + type Brand<T, B extends string> = T & { readonly [__brand]: B } 29 + 30 + export type Branded<T, B extends string> = Brand<T, B> 31 + export const brand = <T, B extends string>(value: T): Branded<T, B> => value as Branded<T, B> 32 + 33 + // ───────────────────────────────────────────────────────────────────────────── 34 + // Refinements — Types with validated properties 35 + // ───────────────────────────────────────────────────────────────────────────── 36 + 37 + declare const __refine: unique symbol 38 + type Refine<T, R extends string> = T & { readonly [__refine]: R } 39 + 40 + export type Refined<T, R extends string> = Refine<T, R> 41 + 42 + export const refine = <T, R extends string>( 43 + predicate: (x: T) => boolean 44 + ) => (value: T): Refined<T, R> | undefined => 45 + predicate(value) ? value as Refined<T, R> : undefined 46 + 47 + export const unsafeRefine = <T, R extends string>(value: T): Refined<T, R> => 48 + value as Refined<T, R> 49 + 50 + // Common refinement types 51 + export type NonEmpty = "NonEmpty" 52 + export type Sorted = "Sorted" 53 + export type Positive = "Positive" 54 + export type NonNegative = "NonNegative" 55 + export type Normalized = "Normalized" 56 + export type Integer = "Integer" 57 + 58 + // Refinement constructors 59 + export const positive = refine<number, Positive>(x => x > 0) 60 + export const nonNegative = refine<number, NonNegative>(x => x >= 0) 61 + export const normalized = refine<number, Normalized>(x => x >= 0 && x <= 1) 62 + export const integer = refine<number, Integer>(x => Number.isInteger(x)) 63 + 64 + // ───────────────────────────────────────────────────────────────────────────── 65 + // Result — Errors as values 66 + // ───────────────────────────────────────────────────────────────────────────── 67 + 68 + export type Ok<T> = { readonly _tag: "Ok"; readonly value: T } 69 + export type Err<E> = { readonly _tag: "Err"; readonly error: E } 70 + export type Result<T, E> = Ok<T> | Err<E> 71 + 72 + export const ok = <T>(value: T): Ok<T> => ({ _tag: "Ok", value }) 73 + export const err = <E>(error: E): Err<E> => ({ _tag: "Err", error }) 74 + 75 + export const mapResult = <T, U, E>(f: (x: T) => U) => 76 + (r: Result<T, E>): Result<U, E> => 77 + r._tag === "Ok" ? ok(f(r.value)) : r 78 + 79 + export const chainResult = <T, U, E>(f: (x: T) => Result<U, E>) => 80 + (r: Result<T, E>): Result<U, E> => 81 + r._tag === "Ok" ? f(r.value) : r 82 + 83 + export const unwrapOr = <T, E>(defaultValue: T) => 84 + (r: Result<T, E>): T => 85 + r._tag === "Ok" ? r.value : defaultValue 86 + 87 + export const tryCatch = <T, E = Error>( 88 + f: () => T, 89 + onError: (e: unknown) => E = e => e as E 90 + ): Result<T, E> => { 91 + try { return ok(f()) } 92 + catch (e) { return err(onError(e)) } 93 + } 94 + 95 + // ───────────────────────────────────────────────────────────────────────────── 96 + // Option — Nullable done right 97 + // ───────────────────────────────────────────────────────────────────────────── 98 + 99 + export type Some<T> = { readonly _tag: "Some"; readonly value: T } 100 + export type None = { readonly _tag: "None" } 101 + export type Option<T> = Some<T> | None 102 + 103 + export const some = <T>(value: T): Some<T> => ({ _tag: "Some", value }) 104 + export const none: None = { _tag: "None" } 105 + 106 + export const fromNullable = <T>(value: T | null | undefined): Option<T> => 107 + value == null ? none : some(value) 108 + 109 + export const mapOption = <T, U>(f: (x: T) => U) => 110 + (o: Option<T>): Option<U> => 111 + o._tag === "Some" ? some(f(o.value)) : none 112 + 113 + export const getOrElse = <T>(defaultValue: T) => 114 + (o: Option<T>): T => 115 + o._tag === "Some" ? o.value : defaultValue 116 + 117 + export const flatMapOption = <T, U>(f: (x: T) => Option<U>) => 118 + (o: Option<T>): Option<U> => 119 + o._tag === "Some" ? f(o.value) : none 120 + 121 + // ───────────────────────────────────────────────────────────────────────────── 122 + // Pattern Matching 123 + // ───────────────────────────────────────────────────────────────────────────── 124 + 125 + type Pattern<T, R> = { 126 + [K in T extends { _tag: infer Tag } ? Tag & string : never]: 127 + (value: Extract<T, { _tag: K }>) => R 128 + } 129 + 130 + /** Exhaustive pattern matching on tagged unions */ 131 + export const match = <T extends { _tag: string }>(value: T) => 132 + <R>(patterns: Pattern<T, R>): R => 133 + patterns[value._tag as keyof typeof patterns](value as any) 134 + 135 + /** Match with wildcard fallback */ 136 + export const matchOr = <T extends { _tag: string }, R>(defaultValue: R) => 137 + (value: T) => 138 + (patterns: Partial<Pattern<T, R>>): R => 139 + (patterns[value._tag as keyof typeof patterns] as any)?.(value) ?? defaultValue 140 + 141 + /** Predicate-based matching */ 142 + export const when = <T>(value: T) => 143 + <R>(...cases: Array<[(v: T) => boolean, (v: T) => R]>) => 144 + (otherwise: (v: T) => R): R => 145 + (cases.find(([pred]) => pred(value))?.[1] ?? otherwise)(value) 146 + 147 + /** Literal matching */ 148 + export const matchLiteral = <T extends string | number>(value: T) => 149 + <R>(patterns: Record<T, () => R>): R => 150 + patterns[value]() 151 + 152 + /** Convenience matchers */ 153 + export const matchResult = <T, E, R>( 154 + onOk: (value: T) => R, 155 + onErr: (error: E) => R 156 + ) => (r: Result<T, E>): R => 157 + match(r)({ Ok: ({ value }) => onOk(value), Err: ({ error }) => onErr(error) }) 158 + 159 + export const matchOption = <T, R>( 160 + onSome: (value: T) => R, 161 + onNone: () => R 162 + ) => (o: Option<T>): R => 163 + match(o)({ Some: ({ value }) => onSome(value), None: onNone }) 164 + 165 + export const fold = matchResult 166 + export const maybe = <T, R>(onNone: R, onSome: (value: T) => R) => 167 + (o: Option<T>): R => matchOption(onSome, () => onNone)(o) 168 + 169 + // ───────────────────────────────────────────────────────────────────────────── 170 + // Tracked Arrays — Properties follow transformations 171 + // ───────────────────────────────────────────────────────────────────────────── 172 + 173 + declare const __props: unique symbol 174 + type Props<P extends string> = { readonly [__props]: P } 175 + 176 + export type Arr<T, P extends string = never> = readonly T[] & Props<P> 177 + 178 + export const arr = <T>(xs: readonly T[]): Arr<T> => xs as Arr<T> 179 + 180 + export const sort = <T, P extends string>(compare: (a: T, b: T) => number) => 181 + (xs: Arr<T, P>): Arr<T, P | Sorted> => 182 + [...xs].sort(compare) as unknown as Arr<T, P | Sorted> 183 + 184 + export const sortNum = <P extends string>(xs: Arr<number, P>): Arr<number, P | Sorted> => 185 + sort<number, P>((a, b) => a - b)(xs) 186 + 187 + export const sortBy = <T, P extends string>(f: (x: T) => number) => 188 + (xs: Arr<T, P>): Arr<T, P | Sorted> => 189 + sort<T, P>((a, b) => f(a) - f(b))(xs) 190 + 191 + export const nonEmpty = <T, P extends string>(xs: Arr<T, P>): Option<Arr<T, P | NonEmpty>> => 192 + xs.length > 0 ? some(xs as Arr<T, P | NonEmpty>) : none 193 + 194 + export const map = <T, U, P extends string>(f: (x: T) => U) => 195 + (xs: Arr<T, P>): Arr<U, P> => 196 + xs.map(f) as unknown as Arr<U, P> 197 + 198 + export const filter = <T, P extends string>(pred: (x: T) => boolean) => 199 + (xs: Arr<T, P>): Arr<T, P> => 200 + xs.filter(pred) as unknown as Arr<T, P> 201 + 202 + export const reduce = <T, U, P extends string>(f: (acc: U, x: T) => U, initial: U) => 203 + (xs: Arr<T, P>): U => 204 + xs.reduce(f, initial) 205 + 206 + export const flatMapArr = <T, U, P extends string>(f: (x: T) => readonly U[]) => 207 + (xs: Arr<T, P>): Arr<U> => 208 + xs.flatMap(f) as unknown as Arr<U> 209 + 210 + export const take = <T, P extends string>(n: number) => 211 + (xs: Arr<T, P>): Arr<T, P> => 212 + xs.slice(0, n) as unknown as Arr<T, P> 213 + 214 + export const drop = <T, P extends string>(n: number) => 215 + (xs: Arr<T, P>): Arr<T, P> => 216 + xs.slice(n) as unknown as Arr<T, P> 217 + 218 + type Has<P extends string, Required extends string> = Required extends P ? true : false 219 + 220 + export const head = <T, P extends string>( 221 + xs: Has<P, NonEmpty> extends true ? Arr<T, P> : never 222 + ): T => (xs as readonly T[])[0]! 223 + 224 + export const last = <T, P extends string>( 225 + xs: Has<P, NonEmpty> extends true ? Arr<T, P> : never 226 + ): T => (xs as readonly T[])[(xs as readonly T[]).length - 1]! 227 + 228 + export const binarySearch = <T>(compare: (a: T, b: T) => number) => 229 + (target: T) => 230 + <P extends string>(xs: Has<P, Sorted> extends true ? Arr<T, P> : never): Option<T> => { 231 + const go = (lo: number, hi: number): Option<T> => 232 + lo > hi 233 + ? none 234 + : pipe( 235 + (lo + hi) >>> 1, 236 + mid => pipe( 237 + compare((xs as readonly T[])[mid]!, target), 238 + cmp => 239 + cmp === 0 ? some((xs as readonly T[])[mid]!) : 240 + cmp < 0 ? go(mid + 1, hi) : 241 + go(lo, mid - 1) 242 + ) 243 + ) 244 + return go(0, (xs as readonly T[]).length - 1) 245 + } 246 + 247 + export const binarySearchNum = binarySearch<number>((a, b) => a - b) 248 + 249 + // ───────────────────────────────────────────────────────────────────────────── 250 + // Units — Compile-time dimensional analysis 251 + // ───────────────────────────────────────────────────────────────────────────── 252 + 253 + declare const __unit: unique symbol 254 + type Unit<U extends string> = { readonly [__unit]: U } 255 + 256 + export type Quantity<T extends number, U extends string> = T & Unit<U> 257 + 258 + export const quantity = <U extends string>(value: number): Quantity<number, U> => 259 + value as Quantity<number, U> 260 + 261 + export type Meters = "m" 262 + export type Seconds = "s" 263 + export type Kilograms = "kg" 264 + export type MetersPerSecond = "m/s" 265 + 266 + export const meters = (n: number): Quantity<number, Meters> => quantity<Meters>(n) 267 + export const seconds = (n: number): Quantity<number, Seconds> => quantity<Seconds>(n) 268 + export const kilograms = (n: number): Quantity<number, Kilograms> => quantity<Kilograms>(n) 269 + export const mps = (n: number): Quantity<number, MetersPerSecond> => quantity<MetersPerSecond>(n) 270 + 271 + export const addQ = <U extends string>(a: Quantity<number, U>, b: Quantity<number, U>): Quantity<number, U> => 272 + (a + b) as Quantity<number, U> 273 + 274 + export const subQ = <U extends string>(a: Quantity<number, U>, b: Quantity<number, U>): Quantity<number, U> => 275 + (a - b) as Quantity<number, U> 276 + 277 + export const scaleQ = <U extends string>(scalar: number) => 278 + (q: Quantity<number, U>): Quantity<number, U> => 279 + (q * scalar) as Quantity<number, U> 280 + 281 + export const velocity = (distance: Quantity<number, Meters>, time: Quantity<number, Seconds>): Quantity<number, MetersPerSecond> => 282 + ((distance as number) / (time as number)) as Quantity<number, MetersPerSecond> 283 + 284 + // ───────────────────────────────────────────────────────────────────────────── 285 + // Typestate — State machines via phantom types 286 + // ───────────────────────────────────────────────────────────────────────────── 287 + 288 + declare const __state: unique symbol 289 + type State<S extends string> = { readonly [__state]: S } 290 + 291 + export type Entity<T, S extends string> = T & State<S> 292 + 293 + export const entity = <T, S extends string>(value: T): Entity<T, S> => 294 + value as Entity<T, S> 295 + 296 + export const transition = <T, _S1 extends string, S2 extends string>( 297 + transform: (x: T) => T = x => x 298 + ) => (e: Entity<T, _S1>): Entity<T, S2> => 299 + transform(e) as unknown as Entity<T, S2> 300 + 301 + // ───────────────────────────────────────────────────────────────────────────── 302 + // Composition — pipe & flow 303 + // ───────────────────────────────────────────────────────────────────────────── 304 + 305 + export function pipe<A>(a: A): A 306 + export function pipe<A, B>(a: A, ab: (a: A) => B): B 307 + export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C 308 + export function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D 309 + export function pipe<A, B, C, D, E>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): E 310 + export function pipe<A, B, C, D, E, F>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): F 311 + export function pipe<A, B, C, D, E, F, G>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G): G 312 + export function pipe<A, B, C, D, E, F, G, H>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G, gh: (g: G) => H): H 313 + export function pipe(a: unknown, ...fns: Function[]): unknown { 314 + return fns.reduce((acc, fn) => fn(acc), a) 315 + } 316 + 317 + export function flow<A, B>(ab: (a: A) => B): (a: A) => B 318 + export function flow<A, B, C>(ab: (a: A) => B, bc: (b: B) => C): (a: A) => C 319 + export function flow<A, B, C, D>(ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (a: A) => D 320 + export function flow<A, B, C, D, E>(ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (a: A) => E 321 + export function flow<A, B, C, D, E, F>(ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): (a: A) => F 322 + export function flow(...fns: Function[]): Function { 323 + return (a: unknown) => fns.reduce((acc, fn) => fn(acc), a) 324 + } 325 + 326 + // ───────────────────────────────────────────────────────────────────────────── 327 + // Guards 328 + // ───────────────────────────────────────────────────────────────────────────── 329 + 330 + export type Guard<T> = (x: unknown) => x is T 331 + 332 + export const and = <A, B>(ga: Guard<A>, gb: Guard<B>): Guard<A & B> => 333 + (x): x is A & B => ga(x) && gb(x) 334 + 335 + export const or = <A, B>(ga: Guard<A>, gb: Guard<B>): Guard<A | B> => 336 + (x): x is A | B => ga(x) || gb(x) 337 + 338 + export const isString = (x: unknown): x is string => typeof x === "string" 339 + export const isNumber = (x: unknown): x is number => typeof x === "number" 340 + export const isBoolean = (x: unknown): x is boolean => typeof x === "boolean" 341 + 342 + export const guardedRefine = <T, R extends string>(guard: Guard<T>, predicate: (x: T) => boolean) => 343 + (x: unknown): x is Refined<T, R> => guard(x) && predicate(x) 344 + 345 + export const isPositive = guardedRefine<number, Positive>(isNumber, x => x > 0) 346 + export const isInteger = guardedRefine<number, Integer>(isNumber, Number.isInteger) 347 + 348 + // ───────────────────────────────────────────────────────────────────────────── 349 + // Utilities 350 + // ───────────────────────────────────────────────────────────────────────────── 351 + 352 + export const id = <T>(x: T): T => x 353 + export const constant = <T>(x: T) => (): T => x 354 + export const flip = <A, B, C>(f: (a: A) => (b: B) => C) => (b: B) => (a: A): C => f(a)(b) 355 + export const apply = <A, B>(a: A) => (f: (a: A) => B): B => f(a) 356 + export const tap = <T>(f: (x: T) => void) => (x: T): T => (f(x), x) 357 + export const trace = <T>(label: string) => tap<T>(x => console.log(label, x)) 358 + 359 + export const cond = <T>(predicate: boolean, onTrue: T, onFalse: T): T => 360 + predicate ? onTrue : onFalse 361 + 362 + export const condL = <T>(predicate: boolean, onTrue: () => T, onFalse: () => T): T => 363 + predicate ? onTrue() : onFalse() 364 + 365 + export const ifElse = <T, E>(predicate: boolean) => 366 + (onTrue: () => Result<T, E>, onFalse: () => Result<T, E>): Result<T, E> => 367 + predicate ? onTrue() : onFalse() 368 + 369 + export const ifElseOpt = <T>(predicate: boolean) => 370 + (onTrue: () => Option<T>, onFalse: () => Option<T>): Option<T> => 371 + predicate ? onTrue() : onFalse() 372 + 373 + 374 + // ============================================================================= 375 + // PART II: FIBER RUNTIME 376 + // ============================================================================= 377 + 378 + // ───────────────────────────────────────────────────────────────────────────── 379 + // Fiber Identity & Status 380 + // ───────────────────────────────────────────────────────────────────────────── 381 + 382 + export type FiberId = { readonly _tag: "FiberId"; readonly id: number } 383 + 384 + let fiberIdCounter = 0 385 + export const makeFiberId = (): FiberId => ({ _tag: "FiberId", id: ++fiberIdCounter }) 386 + 387 + export type FiberStatus<A, E> = 388 + | { readonly _tag: "Pending" } 389 + | { readonly _tag: "Running" } 390 + | { readonly _tag: "Suspended" } 391 + | { readonly _tag: "Succeeded"; readonly value: A } 392 + | { readonly _tag: "Failed"; readonly error: E } 393 + | { readonly _tag: "Interrupted"; readonly by: FiberId } 394 + 395 + // ───────────────────────────────────────────────────────────────────────────── 396 + // Exit — Result of fiber completion 397 + // ───────────────────────────────────────────────────────────────────────────── 398 + 399 + export type Exit<A, E> = 400 + | { readonly _tag: "Success"; readonly value: A } 401 + | { readonly _tag: "Failure"; readonly error: E } 402 + | { readonly _tag: "Interrupted"; readonly by: FiberId } 403 + 404 + export const Exit = { 405 + succeed: <A>(value: A): Exit<A, never> => ({ _tag: "Success", value }), 406 + fail: <E>(error: E): Exit<never, E> => ({ _tag: "Failure", error }), 407 + interrupt: <A, E>(by: FiberId): Exit<A, E> => ({ _tag: "Interrupted", by }), 408 + 409 + isSuccess: <A, E>(exit: Exit<A, E>): exit is Exit<A, never> & { _tag: "Success" } => 410 + exit._tag === "Success", 411 + isFailure: <A, E>(exit: Exit<A, E>): exit is Exit<never, E> & { _tag: "Failure" } => 412 + exit._tag === "Failure", 413 + isInterrupted: <A, E>(exit: Exit<A, E>): exit is Exit<A, E> & { _tag: "Interrupted" } => 414 + exit._tag === "Interrupted", 415 + } 416 + 417 + // ───────────────────────────────────────────────────────────────────────────── 418 + // Fiber — A running computation 419 + // ───────────────────────────────────────────────────────────────────────────── 420 + 421 + export type Fiber<A, E = never> = { 422 + readonly _tag: "Fiber" 423 + readonly id: FiberId 424 + readonly status: () => FiberStatus<A, E> 425 + readonly await: () => Promise<Exit<A, E>> 426 + readonly interrupt: () => Promise<Exit<A, E>> 427 + readonly join: () => Eff<A, E, never> 428 + } 429 + 430 + // ───────────────────────────────────────────────────────────────────────────── 431 + // Eff — Effect as data (instructions for the runtime) 432 + // ───────────────────────────────────────────────────────────────────────────── 433 + 434 + /** 435 + * Eff<A, E, R> — The core effect type 436 + * 437 + * A = Success type 438 + * E = Error type (never = cannot fail) 439 + * R = Requirements (dependencies needed to run) 440 + * 441 + * Effects are DATA — a tree of instructions that the fiber runtime interprets. 442 + * This enables cancellation, stack safety, and inspection. 443 + */ 444 + export type Eff<A, E = never, R = unknown> = 445 + | { readonly _tag: "Succeed"; readonly value: A } 446 + | { readonly _tag: "Fail"; readonly error: E } 447 + | { readonly _tag: "Sync"; readonly thunk: () => A } 448 + | { readonly _tag: "Async"; readonly register: (resume: (result: Exit<A, E>) => void) => void | (() => void) } 449 + | { readonly _tag: "FlatMap"; readonly effect: Eff<any, E, R>; readonly f: (a: any) => Eff<A, E, R> } 450 + | { readonly _tag: "Fold"; readonly effect: Eff<any, any, R>; readonly onFailure: (e: any) => Eff<A, E, R>; readonly onSuccess: (a: any) => Eff<A, E, R> } 451 + | { readonly _tag: "Fork"; readonly effect: Eff<any, any, R> } 452 + | { readonly _tag: "GetFiberId" } 453 + | { readonly _tag: "CheckInterrupt" } 454 + | { readonly _tag: "InterruptAs"; readonly fiberId: FiberId } 455 + | { readonly _tag: "Yield" } 456 + | { readonly _tag: "Access"; readonly f: (r: R) => Eff<A, E, any> } 457 + | { readonly _tag: "Provide"; readonly effect: Eff<A, E, any>; readonly env: unknown } 458 + 459 + // ───────────────────────────────────────────────────────────────────────────── 460 + // Effect Constructors 461 + // ───────────────────────────────────────────────────────────────────────────── 462 + 463 + /** Succeed with a value */ 464 + export const succeed = <A>(value: A): Eff<A, never, unknown> => 465 + ({ _tag: "Succeed", value }) 466 + 467 + /** Fail with an error */ 468 + export const fail = <E>(error: E): Eff<never, E, unknown> => 469 + ({ _tag: "Fail", error }) 470 + 471 + /** Lift a synchronous computation */ 472 + export const sync = <A>(thunk: () => A): Eff<A, never, unknown> => 473 + ({ _tag: "Sync", thunk }) 474 + 475 + /** Lift a synchronous computation that may throw */ 476 + export const attempt = <A>(thunk: () => A): Eff<A, unknown, unknown> => 477 + ({ 478 + _tag: "Fold", 479 + effect: { _tag: "Sync", thunk } as Eff<A, never, unknown>, 480 + onFailure: (e) => fail(e), 481 + onSuccess: (a) => succeed(a) 482 + }) 483 + 484 + /** Create an async effect */ 485 + export const async = <A, E = never>( 486 + register: (resume: (result: Exit<A, E>) => void) => void | (() => void) 487 + ): Eff<A, E, unknown> => 488 + ({ _tag: "Async", register }) 489 + 490 + /** Wrap a Promise */ 491 + export const fromPromise = <A>(promise: () => Promise<A>): Eff<A, unknown, unknown> => 492 + async((resume) => { 493 + promise() 494 + .then(a => resume(Exit.succeed(a))) 495 + .catch(e => resume(Exit.fail(e))) 496 + }) 497 + 498 + /** Yield control to scheduler */ 499 + export const yieldNow: Eff<void, never, unknown> = { _tag: "Yield" } 500 + 501 + /** Get current fiber's ID */ 502 + export const fiberId: Eff<FiberId, never, unknown> = { _tag: "GetFiberId" } 503 + 504 + /** Check for interruption */ 505 + export const checkInterrupt: Eff<void, never, unknown> = { _tag: "CheckInterrupt" } 506 + 507 + // ───────────────────────────────────────────────────────────────────────────── 508 + // Effect Transformations 509 + // ───────────────────────────────────────────────────────────────────────────── 510 + 511 + /** Map over success value */ 512 + export const mapEff = <A, B>(f: (a: A) => B) => 513 + <E, R>(effect: Eff<A, E, R>): Eff<B, E, R> => 514 + ({ _tag: "FlatMap", effect, f: (a) => succeed(f(a)) }) 515 + 516 + /** Chain effects (flatMap) */ 517 + export const flatMap = <A, B, E2, R2>(f: (a: A) => Eff<B, E2, R2>) => 518 + <E, R>(effect: Eff<A, E, R>): Eff<B, E | E2, R & R2> => 519 + ({ _tag: "FlatMap", effect, f }) as Eff<B, E | E2, R & R2> 520 + 521 + /** Handle both success and failure */ 522 + export const foldEff = <A, E, B, E2, R2>( 523 + onFailure: (e: E) => Eff<B, E2, R2>, 524 + onSuccess: (a: A) => Eff<B, E2, R2> 525 + ) => <R>(effect: Eff<A, E, R>): Eff<B, E2, R & R2> => 526 + ({ _tag: "Fold", effect, onFailure, onSuccess }) as Eff<B, E2, R & R2> 527 + 528 + /** Catch all errors */ 529 + export const catchAll = <E, A2, E2, R2>(f: (e: E) => Eff<A2, E2, R2>) => 530 + <A, R>(effect: Eff<A, E, R>): Eff<A | A2, E2, R & R2> => 531 + ({ _tag: "Fold", effect, onFailure: f, onSuccess: succeed }) as Eff<A | A2, E2, R & R2> 532 + 533 + /** Access environment */ 534 + export const access = <R, A>(f: (r: R) => A): Eff<A, never, R> => 535 + ({ _tag: "Access", f: (r) => succeed(f(r)) }) as Eff<A, never, R> 536 + 537 + /** Access environment and return an effect */ 538 + export const accessEff = <R, A, E>(f: (r: R) => Eff<A, E, unknown>): Eff<A, E, R> => 539 + ({ _tag: "Access", f }) as Eff<A, E, R> 540 + 541 + /** Provide environment */ 542 + export const provide = <R>(env: R) => 543 + <A, E>(effect: Eff<A, E, R>): Eff<A, E, unknown> => 544 + ({ _tag: "Provide", effect, env }) 545 + 546 + // ───────────────────────────────────────────────────────────────────────────── 547 + // Concurrency Primitives 548 + // ───────────────────────────────────────────────────────────────────────────── 549 + 550 + /** Fork an effect into a new fiber */ 551 + export const fork = <A, E, R>(effect: Eff<A, E, R>): Eff<Fiber<A, E>, never, R> => 552 + ({ _tag: "Fork", effect }) as Eff<Fiber<A, E>, never, R> 553 + 554 + /** Interrupt a fiber */ 555 + export const interruptFiber = (fiber: Fiber<any, any>): Eff<void, never, unknown> => 556 + async((resume) => { 557 + fiber.interrupt().then(() => resume(Exit.succeed(undefined))) 558 + }) 559 + 560 + /** Wait for a fiber's result */ 561 + export const join = <A, E>(fiber: Fiber<A, E>): Eff<A, E, unknown> => 562 + async((resume) => { 563 + fiber.await().then(exit => resume(exit)) 564 + }) 565 + 566 + /** Race two effects, cancel the loser */ 567 + export const race = <A, E, R>(left: Eff<A, E, R>, right: Eff<A, E, R>): Eff<A, E, R> => 568 + flatMap((leftFiber: Fiber<A, E>) => 569 + flatMap((rightFiber: Fiber<A, E>) => 570 + async<A, E>((resume) => { 571 + let done = false 572 + 573 + const finish = (exit: Exit<A, E>, loser: Fiber<A, E>) => { 574 + if (done) return 575 + done = true 576 + loser.interrupt() 577 + resume(exit) 578 + } 579 + 580 + leftFiber.await().then(exit => finish(exit, rightFiber)) 581 + rightFiber.await().then(exit => finish(exit, leftFiber)) 582 + }) 583 + )(fork(right)) 584 + )(fork(left)) as Eff<A, E, R> 585 + 586 + /** Run effects in parallel */ 587 + export const all = <T extends readonly Eff<any, any, any>[]>( 588 + effects: [...T] 589 + ): Eff< 590 + { [K in keyof T]: T[K] extends Eff<infer A, any, any> ? A : never }, 591 + T[number] extends Eff<any, infer E, any> ? E : never, 592 + T[number] extends Eff<any, any, infer R> ? R : never 593 + > => { 594 + const forkAll = effects.reduce( 595 + (acc, eff) => flatMap((fibers: Fiber<any, any>[]) => 596 + mapEff((f: Fiber<any, any>) => [...fibers, f])(fork(eff)) 597 + )(acc), 598 + succeed([] as Fiber<any, any>[]) 599 + ) 600 + 601 + return flatMap((fibers: Fiber<any, any>[]) => 602 + async((resume) => { 603 + const results: any[] = new Array(fibers.length) 604 + let completed = 0 605 + let failed = false 606 + 607 + fibers.forEach((fiber, i) => { 608 + fiber.await().then(exit => { 609 + if (failed) return 610 + 611 + if (Exit.isSuccess(exit)) { 612 + results[i] = exit.value 613 + completed++ 614 + if (completed === fibers.length) { 615 + resume(Exit.succeed(results as any)) 616 + } 617 + } else { 618 + failed = true 619 + fibers.forEach(f => f.interrupt()) 620 + resume(exit as any) 621 + } 622 + }) 623 + }) 624 + }) 625 + )(forkAll) as any 626 + } 627 + 628 + // ───────────────────────────────────────────────────────────────────────────── 629 + // Effect Combinators 630 + // ───────────────────────────────────────────────────────────────────────────── 631 + 632 + /** Delay execution */ 633 + export const sleep = (ms: number): Eff<void, never, unknown> => 634 + async((resume) => { 635 + const timer = setTimeout(() => resume(Exit.succeed(undefined)), ms) 636 + return () => clearTimeout(timer) 637 + }) 638 + 639 + /** Timeout an effect */ 640 + export const timeout = <A, E, R>(ms: number) => 641 + (effect: Eff<A, E, R>): Eff<A | null, E, R> => 642 + race( 643 + mapEff(() => null as A | null)(sleep(ms)), 644 + mapEff((a: A) => a as A | null)(effect) 645 + ) as Eff<A | null, E, R> 646 + 647 + /** Retry on failure */ 648 + export const retry = (attempts: number) => 649 + <A, E, R>(effect: Eff<A, E, R>): Eff<A, E, R> => { 650 + const loop = (remaining: number): Eff<A, E, R> => 651 + remaining <= 1 652 + ? effect 653 + : foldEff<A, E, A, E, R>( 654 + () => loop(remaining - 1), 655 + (a) => succeed(a) 656 + )(effect) as Eff<A, E, R> 657 + 658 + return loop(attempts) 659 + } 660 + 661 + /** Repeat an effect n times */ 662 + export const repeatEff = (n: number) => 663 + <A, E, R>(effect: Eff<A, E, R>): Eff<A, E, R> => { 664 + const loop = (remaining: number, last: A): Eff<A, E, R> => 665 + remaining <= 0 666 + ? succeed(last) 667 + : flatMap((a: A) => loop(remaining - 1, a))(effect) as Eff<A, E, R> 668 + 669 + return flatMap((a: A) => loop(n - 1, a))(effect) as Eff<A, E, R> 670 + } 671 + 672 + /** Tap for side effects */ 673 + export const tapEff = <A>(f: (a: A) => void) => 674 + <E, R>(effect: Eff<A, E, R>): Eff<A, E, R> => 675 + flatMap((a: A) => sync(() => { f(a); return a }))(effect) as Eff<A, E, R> 676 + 677 + // ───────────────────────────────────────────────────────────────────────────── 678 + // Fiber Runtime — The interpreter 679 + // ───────────────────────────────────────────────────────────────────────────── 680 + 681 + export type RuntimeConfig = { 682 + readonly yieldInterval: number 683 + } 684 + 685 + export const defaultRuntimeConfig: RuntimeConfig = { 686 + yieldInterval: 100 687 + } 688 + 689 + /** Run an effect as a fiber */ 690 + export const runFiber = <A, E, R>( 691 + effect: Eff<A, E, R>, 692 + env: R, 693 + config: RuntimeConfig = defaultRuntimeConfig 694 + ): Fiber<A, E> => { 695 + const id = makeFiberId() 696 + let status: FiberStatus<A, E> = { _tag: "Pending" } 697 + let interrupted = false 698 + let interruptedBy: FiberId | null = null 699 + 700 + const observers: Array<(exit: Exit<A, E>) => void> = [] 701 + 702 + const notifyObservers = (exit: Exit<A, E>) => { 703 + observers.forEach(cb => cb(exit)) 704 + observers.length = 0 705 + } 706 + 707 + const complete = (exit: Exit<A, E>) => { 708 + if (Exit.isSuccess(exit)) { 709 + status = { _tag: "Succeeded", value: exit.value } 710 + } else if (Exit.isFailure(exit)) { 711 + status = { _tag: "Failed", error: exit.error } 712 + } else { 713 + const int = exit as { _tag: "Interrupted"; by: FiberId } 714 + status = { _tag: "Interrupted", by: int.by } 715 + } 716 + notifyObservers(exit) 717 + } 718 + 719 + const run = (current: Eff<any, any, any>, currentEnv: any, k: (exit: Exit<any, any>) => void) => { 720 + let opCount = 0 721 + 722 + type Cont = 723 + | { _tag: "FlatMap"; f: (a: any) => Eff<any, any, any> } 724 + | { _tag: "Fold"; onFailure: (e: any) => Eff<any, any, any>; onSuccess: (a: any) => Eff<any, any, any> } 725 + | { _tag: "PopEnv"; env: any } 726 + 727 + const stack: Cont[] = [] 728 + const envStack: any[] = [currentEnv] 729 + 730 + const unwindStack = ( 731 + stack: Cont[], 732 + value: any, 733 + isSuccess: boolean 734 + ): { _tag: "Effect"; effect: Eff<any, any, any> } | null => { 735 + while (stack.length > 0) { 736 + const cont = stack.pop()! 737 + 738 + if (cont._tag === "PopEnv") { 739 + envStack.pop() 740 + continue 741 + } 742 + 743 + if (cont._tag === "FlatMap") { 744 + if (isSuccess) { 745 + return { _tag: "Effect", effect: cont.f(value) } 746 + } 747 + continue 748 + } 749 + 750 + if (cont._tag === "Fold") { 751 + return { 752 + _tag: "Effect", 753 + effect: isSuccess ? cont.onSuccess(value) : cont.onFailure(value) 754 + } 755 + } 756 + } 757 + return null 758 + } 759 + 760 + const loop = (): void => { 761 + if (interrupted) { 762 + k(Exit.interrupt(interruptedBy!)) 763 + return 764 + } 765 + 766 + opCount++ 767 + if (opCount >= config.yieldInterval) { 768 + opCount = 0 769 + setTimeout(loop, 0) 770 + return 771 + } 772 + 773 + const env = envStack[envStack.length - 1] 774 + 775 + switch (current._tag) { 776 + case "Succeed": { 777 + const next = unwindStack(stack, current.value, true) 778 + if (next === null) k(Exit.succeed(current.value)) 779 + else { current = next.effect; loop() } 780 + break 781 + } 782 + 783 + case "Fail": { 784 + const next = unwindStack(stack, current.error, false) 785 + if (next === null) k(Exit.fail(current.error)) 786 + else { current = next.effect; loop() } 787 + break 788 + } 789 + 790 + case "Sync": { 791 + try { current = succeed(current.thunk()) } 792 + catch (e) { current = fail(e) } 793 + loop() 794 + break 795 + } 796 + 797 + case "Async": { 798 + status = { _tag: "Running" } 799 + current.register((exit) => { 800 + if (Exit.isSuccess(exit)) current = succeed(exit.value) 801 + else if (Exit.isFailure(exit)) current = fail(exit.error) 802 + else { 803 + const int = exit as { _tag: "Interrupted"; by: FiberId } 804 + interrupted = true 805 + interruptedBy = int.by 806 + current = succeed(undefined) 807 + } 808 + loop() 809 + }) 810 + break 811 + } 812 + 813 + case "FlatMap": { 814 + stack.push({ _tag: "FlatMap", f: current.f }) 815 + current = current.effect 816 + loop() 817 + break 818 + } 819 + 820 + case "Fold": { 821 + stack.push({ _tag: "Fold", onFailure: current.onFailure, onSuccess: current.onSuccess }) 822 + current = current.effect 823 + loop() 824 + break 825 + } 826 + 827 + case "Fork": { 828 + const childFiber = runFiber(current.effect, env, config) 829 + current = succeed(childFiber) 830 + loop() 831 + break 832 + } 833 + 834 + case "GetFiberId": { 835 + current = succeed(id) 836 + loop() 837 + break 838 + } 839 + 840 + case "CheckInterrupt": { 841 + if (interrupted) { k(Exit.interrupt(interruptedBy!)); return } 842 + current = succeed(undefined) 843 + loop() 844 + break 845 + } 846 + 847 + case "Yield": { 848 + setTimeout(() => { current = succeed(undefined); loop() }, 0) 849 + break 850 + } 851 + 852 + case "Access": { 853 + current = current.f(env) 854 + loop() 855 + break 856 + } 857 + 858 + case "Provide": { 859 + stack.push({ _tag: "PopEnv", env }) 860 + envStack.push(current.env) 861 + current = current.effect 862 + loop() 863 + break 864 + } 865 + 866 + case "InterruptAs": { 867 + interrupted = true 868 + interruptedBy = current.fiberId 869 + k(Exit.interrupt(current.fiberId)) 870 + break 871 + } 872 + } 873 + } 874 + 875 + status = { _tag: "Running" } 876 + loop() 877 + } 878 + 879 + run(effect, env, complete) 880 + 881 + return { 882 + _tag: "Fiber", 883 + id, 884 + status: () => status, 885 + await: () => new Promise(resolve => { 886 + if (status._tag === "Succeeded") resolve(Exit.succeed(status.value)) 887 + else if (status._tag === "Failed") resolve(Exit.fail(status.error)) 888 + else if (status._tag === "Interrupted") resolve(Exit.interrupt(status.by)) 889 + else observers.push(resolve) 890 + }), 891 + interrupt: () => { 892 + if (status._tag === "Succeeded" || status._tag === "Failed" || status._tag === "Interrupted") { 893 + return Promise.resolve( 894 + status._tag === "Succeeded" ? Exit.succeed(status.value) : 895 + status._tag === "Failed" ? Exit.fail(status.error) : 896 + Exit.interrupt(status.by) 897 + ) 898 + } 899 + interrupted = true 900 + interruptedBy = id 901 + return new Promise(resolve => observers.push(resolve)) 902 + }, 903 + join: () => async((resume) => { 904 + if (status._tag === "Succeeded") resume(Exit.succeed(status.value)) 905 + else if (status._tag === "Failed") resume(Exit.fail(status.error)) 906 + else if (status._tag === "Interrupted") resume(Exit.interrupt(status.by)) 907 + else observers.push(resume) 908 + }) as Eff<A, E, never> 909 + } 910 + } 911 + 912 + // ───────────────────────────────────────────────────────────────────────────── 913 + // Runners 914 + // ───────────────────────────────────────────────────────────────────────────── 915 + 916 + /** Run an effect and return a Promise */ 917 + export const runPromise = <A, E>(effect: Eff<A, E, unknown>): Promise<A> => { 918 + const fiber = runFiber(effect, {}) 919 + return fiber.await().then(exit => { 920 + if (Exit.isSuccess(exit)) return exit.value 921 + if (Exit.isFailure(exit)) throw exit.error 922 + const int = exit as { _tag: "Interrupted"; by: FiberId } 923 + throw new Error(`Fiber interrupted by ${int.by.id}`) 924 + }) 925 + } 926 + 927 + /** Run an effect and return the Exit */ 928 + export const runPromiseExit = <A, E>(effect: Eff<A, E, unknown>): Promise<Exit<A, E>> => { 929 + const fiber = runFiber(effect, {}) 930 + return fiber.await() 931 + } 932 + 933 + /** Run with environment */ 934 + export const runPromiseWith = <A, E, R>(effect: Eff<A, E, R>, env: R): Promise<A> => { 935 + const fiber = runFiber(effect, env) 936 + return fiber.await().then(exit => { 937 + if (Exit.isSuccess(exit)) return exit.value 938 + if (Exit.isFailure(exit)) throw exit.error 939 + const int = exit as { _tag: "Interrupted"; by: FiberId } 940 + throw new Error(`Fiber interrupted by ${int.by.id}`) 941 + }) 942 + } 943 + 944 + 945 + // ============================================================================= 946 + // PART III: EXPORTS SUMMARY 947 + // ============================================================================= 948 + 949 + /* 950 + ┌─────────────────────────────────────────────────────────────────────────────┐ 951 + │ FOUNDATIONS │ 952 + ├─────────────────────────────────────────────────────────────────────────────┤ 953 + │ Brands: Branded, brand │ 954 + │ Refine: Refined, refine, unsafeRefine, positive, nonNegative, etc. │ 955 + │ Result: Result, Ok, Err, ok, err, mapResult, chainResult, unwrapOr │ 956 + │ Option: Option, Some, None, some, none, mapOption, getOrElse, etc. │ 957 + │ Match: match, matchOr, when, matchLiteral, matchResult, matchOption │ 958 + │ Arrays: Arr, arr, sort, sortNum, map, filter, head, last, binarySearch │ 959 + │ Units: Quantity, meters, seconds, velocity, addQ, scaleQ │ 960 + │ State: Entity, entity, transition │ 961 + │ Compose: pipe, flow │ 962 + │ Guards: Guard, and, or, isString, isNumber, guardedRefine │ 963 + │ Utils: id, constant, tap, trace, cond, ifElse │ 964 + ├─────────────────────────────────────────────────────────────────────────────┤ 965 + │ EFFECT SYSTEM │ 966 + ├─────────────────────────────────────────────────────────────────────────────┤ 967 + │ Types: Eff, Fiber, FiberId, Exit, FiberStatus │ 968 + │ Create: succeed, fail, sync, attempt, async, fromPromise │ 969 + │ Transform: mapEff, flatMap, foldEff, catchAll, access, accessEff, provide │ 970 + │ Concur: fork, join, interruptFiber, race, all │ 971 + │ Combine: sleep, timeout, retry, repeatEff, tapEff │ 972 + │ Run: runFiber, runPromise, runPromiseExit, runPromiseWith │ 973 + │ Control: yieldNow, fiberId, checkInterrupt │ 974 + └─────────────────────────────────────────────────────────────────────────────┘ 975 + */
+26
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ESNext"], 4 + "target": "ESNext", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + 8 + "declaration": true, 9 + "declarationMap": true, 10 + "outDir": "./dist", 11 + "rootDir": "./src", 12 + 13 + "strict": true, 14 + "skipLibCheck": true, 15 + "noFallthroughCasesInSwitch": true, 16 + "noUncheckedIndexedAccess": true, 17 + "noImplicitOverride": true, 18 + "noUnusedLocals": true, 19 + "noUnusedParameters": true, 20 + "verbatimModuleSyntax": true, 21 + "isolatedModules": true, 22 + "esModuleInterop": true 23 + }, 24 + "include": ["src/**/*.ts"], 25 + "exclude": ["node_modules", "dist"] 26 + }