An educational pure functional programming library in TypeScript
2
fork

Configure Feed

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

at main 827 lines 23 kB view raw
1import { describe, expect, it } from "bun:test" 2import { 3 access, 4 accessEff, 5 attempt, 6 bracket, 7 catchAll, 8 type Eff, 9 Exit, 10 ensure, 11 fail, 12 fiberId, 13 flatMap, 14 foldEff, 15 fromPromise, 16 mapEff, 17 pipe, 18 provide, 19 repeatEff, 20 retry, 21 runPromise, 22 runPromiseExit, 23 sleep, 24 succeed, 25 sync, 26 tapBoth, 27 tapEff, 28 tapEffect, 29 tapErr, 30 tapError, 31 timeout, 32 yieldNow, 33} from "../src/index" 34 35describe("Effect - Basic Operations", () => { 36 describe("constructors", () => { 37 it("succeed creates successful effect", async () => { 38 const result = await runPromise(succeed(42)) 39 expect(result).toBe(42) 40 }) 41 42 it("fail creates failed effect", async () => { 43 const exit = await runPromiseExit(fail("error")) 44 expect(Exit.isFailure(exit)).toBe(true) 45 if (Exit.isFailure(exit)) { 46 expect(exit.error).toBe("error") 47 } 48 }) 49 50 it("sync lifts synchronous computation", async () => { 51 const result = await runPromise(sync(() => 1 + 1)) 52 expect(result).toBe(2) 53 }) 54 55 it("attempt catches thrown errors", async () => { 56 const effect = attempt(() => { 57 throw new Error("boom") 58 }) 59 const exit = await runPromiseExit(effect) 60 expect(Exit.isFailure(exit)).toBe(true) 61 }) 62 63 it("fromPromise wraps promises", async () => { 64 const result = await runPromise(fromPromise(() => Promise.resolve(42))) 65 expect(result).toBe(42) 66 }) 67 }) 68 69 describe("transformations", () => { 70 it("mapEff transforms success value", async () => { 71 const result = await runPromise( 72 pipe( 73 succeed(5), 74 mapEff((x) => x * 2), 75 ), 76 ) 77 expect(result).toBe(10) 78 }) 79 80 it("flatMap chains effects", async () => { 81 const result = await runPromise( 82 pipe( 83 succeed(5), 84 flatMap((x) => succeed(x * 2)), 85 flatMap((x) => succeed(x + 1)), 86 ), 87 ) 88 expect(result).toBe(11) 89 }) 90 91 it("foldEff handles both success and failure", async () => { 92 const handle = foldEff( 93 (e: string) => succeed(`error: ${e}`), 94 (a: number) => succeed(`success: ${a}`), 95 ) 96 97 expect(await runPromise(pipe(succeed(42), handle))).toBe("success: 42") 98 expect(await runPromise(pipe(fail("oops"), handle))).toBe("error: oops") 99 }) 100 101 it("catchAll recovers from errors", async () => { 102 const result = await runPromise( 103 pipe( 104 fail("error"), 105 catchAll(() => succeed("recovered")), 106 ), 107 ) 108 expect(result).toBe("recovered") 109 }) 110 111 it("flatMap widens error types automatically", async () => { 112 type DbError = { _tag: "DbError" } 113 type NotFound = { _tag: "NotFound" } 114 115 const dbEffect: Eff<number, DbError, unknown> = succeed(42) 116 117 const result = pipe( 118 dbEffect, 119 flatMap( 120 (n): Eff<string, NotFound, unknown> => 121 n > 0 ? succeed(String(n)) : fail({ _tag: "NotFound" }), 122 ), 123 ) 124 125 expect(await runPromise(result)).toBe("42") 126 }) 127 128 it("flatMap widens requirement types automatically", async () => { 129 type Db = { db: string } 130 type Logger = { logger: string } 131 132 const dbEffect: Eff<number, never, Db> = access((_: Db) => 1) 133 134 const result = pipe( 135 dbEffect, 136 flatMap((n) => access((env: Logger) => env.logger + n)), 137 ) 138 139 const value = await runPromise( 140 pipe(result, provide({ db: "pg", logger: "console" } as Db & Logger)), 141 ) 142 expect(value).toBe("console1") 143 }) 144 }) 145 146 describe("ensure", () => { 147 it("passes value through when predicate is true", async () => { 148 const result = await runPromise( 149 pipe( 150 succeed(42), 151 ensure( 152 (n) => n > 0, 153 (n) => ({ _tag: "NotPositive" as const, value: n }), 154 ), 155 ), 156 ) 157 expect(result).toBe(42) 158 }) 159 160 it("fails with mapped error when predicate is false", async () => { 161 const exit = await runPromiseExit( 162 pipe( 163 succeed(-1), 164 ensure( 165 (n) => n > 0, 166 (n) => ({ _tag: "NotPositive" as const, value: n }), 167 ), 168 ), 169 ) 170 expect(Exit.isFailure(exit)).toBe(true) 171 if (Exit.isFailure(exit)) { 172 expect(exit.error).toEqual({ _tag: "NotPositive", value: -1 }) 173 } 174 }) 175 176 it("does not run predicate on failed effect", async () => { 177 let called = false 178 const exit = await runPromiseExit( 179 pipe( 180 fail("original"), 181 ensure( 182 () => { 183 called = true 184 return true 185 }, 186 () => "never", 187 ), 188 ), 189 ) 190 expect(Exit.isFailure(exit)).toBe(true) 191 if (Exit.isFailure(exit)) expect(exit.error).toBe("original") 192 expect(called).toBe(false) 193 }) 194 }) 195 196 describe("tapErr", () => { 197 it("runs side effect on error", async () => { 198 let observed: string | null = null 199 const exit = await runPromiseExit( 200 pipe( 201 fail("boom"), 202 tapErr((e: string) => { 203 observed = e 204 }), 205 ), 206 ) 207 expect(Exit.isFailure(exit)).toBe(true) 208 expect(observed as unknown as string).toBe("boom") 209 }) 210 211 it("error still propagates after tap", async () => { 212 const exit = await runPromiseExit( 213 pipe( 214 fail("boom"), 215 tapErr(() => {}), 216 ), 217 ) 218 expect(Exit.isFailure(exit)).toBe(true) 219 if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 220 }) 221 222 it("does not run side effect on success", async () => { 223 let called = false 224 const result = await runPromise( 225 pipe( 226 succeed(42), 227 tapErr(() => { 228 called = true 229 }), 230 ), 231 ) 232 expect(result).toBe(42) 233 expect(called).toBe(false) 234 }) 235 }) 236 237 describe("accessEff", () => { 238 type Config = { baseUrl: string } 239 240 it("reads environment and returns an effect", async () => { 241 const getUrl = accessEff((env: Config) => succeed(`${env.baseUrl}/users`)) 242 const result = await runPromise( 243 pipe(getUrl, provide<Config>({ baseUrl: "https://api.example.com" })), 244 ) 245 expect(result).toBe("https://api.example.com/users") 246 }) 247 248 it("can return a failing effect from environment", async () => { 249 const maybeUrl = accessEff((env: Config) => 250 env.baseUrl === "" ? fail("empty url" as const) : succeed(env.baseUrl), 251 ) 252 const exit = await runPromiseExit( 253 pipe(maybeUrl, provide<Config>({ baseUrl: "" })), 254 ) 255 expect(Exit.isFailure(exit)).toBe(true) 256 if (Exit.isFailure(exit)) expect(exit.error).toBe("empty url") 257 }) 258 }) 259 260 describe("tapEff", () => { 261 it("runs side effect on success and returns original value", async () => { 262 let observed: number | null = null 263 const result = await runPromise( 264 pipe( 265 succeed(42), 266 tapEff((a: number) => { 267 observed = a 268 }), 269 ), 270 ) 271 expect(result).toBe(42) 272 expect(observed as unknown as number).toBe(42) 273 }) 274 275 it("does not run on failure", async () => { 276 let called = false 277 const exit = await runPromiseExit( 278 pipe( 279 fail("boom") as Eff<number, string, unknown>, 280 tapEff((_a: number) => { 281 called = true 282 }), 283 ), 284 ) 285 expect(Exit.isFailure(exit)).toBe(true) 286 expect(called).toBe(false) 287 }) 288 }) 289 290 describe("dependency injection", () => { 291 type Config = { baseUrl: string } 292 293 it("access reads from environment", async () => { 294 const getBaseUrl = access<Config, string>((env) => env.baseUrl) 295 const result = await runPromise( 296 pipe( 297 getBaseUrl, 298 provide<Config>({ baseUrl: "https://api.example.com" }), 299 ), 300 ) 301 expect(result).toBe("https://api.example.com") 302 }) 303 }) 304 305 describe("bracket", () => { 306 it("runs acquire, use, and release in order", async () => { 307 const log: string[] = [] 308 309 const result = await runPromise( 310 bracket( 311 sync(() => { 312 log.push("acquire") 313 return "resource" 314 }), 315 (r) => 316 sync(() => { 317 log.push(`release:${r}`) 318 }), 319 (r) => 320 sync(() => { 321 log.push(`use:${r}`) 322 return 42 323 }), 324 ), 325 ) 326 327 expect(result).toBe(42) 328 expect(log).toEqual(["acquire", "use:resource", "release:resource"]) 329 }) 330 331 it("runs release even when use fails", async () => { 332 const log: string[] = [] 333 334 const exit = await runPromiseExit( 335 bracket( 336 sync(() => { 337 log.push("acquire") 338 return "resource" 339 }), 340 (r) => 341 sync(() => { 342 log.push(`release:${r}`) 343 }), 344 (_r) => { 345 log.push("use") 346 return fail("boom") 347 }, 348 ), 349 ) 350 351 expect(Exit.isFailure(exit)).toBe(true) 352 if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 353 expect(log).toEqual(["acquire", "use", "release:resource"]) 354 }) 355 356 it("skips use and release when acquire fails", async () => { 357 const log: string[] = [] 358 359 const exit = await runPromiseExit( 360 bracket( 361 fail("acquire-fail") as Eff<string, string, unknown>, 362 (r) => 363 sync(() => { 364 log.push(`release:${r}`) 365 }), 366 (r) => 367 sync(() => { 368 log.push(`use:${r}`) 369 return 42 370 }), 371 ), 372 ) 373 374 expect(Exit.isFailure(exit)).toBe(true) 375 if (Exit.isFailure(exit)) expect(exit.error).toBe("acquire-fail") 376 expect(log).toEqual([]) 377 }) 378 }) 379 380 describe("tapEffect (effectful)", () => { 381 it("on success, side-effect Eff runs and original value is returned", async () => { 382 let observed: number | null = null 383 const result = await runPromise( 384 pipe( 385 succeed(42), 386 tapEffect((a: number) => 387 sync(() => { 388 observed = a 389 }), 390 ), 391 ), 392 ) 393 expect(result).toBe(42) 394 expect(observed as unknown as number).toBe(42) 395 }) 396 397 it("on failure, side-effect Eff does NOT run and error propagates unchanged", async () => { 398 let called = false 399 const exit = await runPromiseExit( 400 pipe( 401 fail("boom") as Eff<number, string, unknown>, 402 tapEffect((_a: number) => 403 sync(() => { 404 called = true 405 }), 406 ), 407 ), 408 ) 409 expect(Exit.isFailure(exit)).toBe(true) 410 if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 411 expect(called).toBe(false) 412 }) 413 414 it("when side-effect Eff fails, that error propagates", async () => { 415 const exit = await runPromiseExit( 416 pipe( 417 succeed(42), 418 tapEffect((_a: number) => fail("side-effect-error")), 419 ), 420 ) 421 expect(Exit.isFailure(exit)).toBe(true) 422 if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 423 }) 424 425 it("works with pipe() in a multi-step chain", async () => { 426 const log: string[] = [] 427 const result = await runPromise( 428 pipe( 429 succeed(10), 430 tapEffect((n: number) => 431 sync(() => { 432 log.push(`first:${n}`) 433 }), 434 ), 435 mapEff((n: number) => n * 2), 436 tapEffect((n: number) => 437 sync(() => { 438 log.push(`second:${n}`) 439 }), 440 ), 441 ), 442 ) 443 expect(result).toBe(20) 444 expect(log).toEqual(["first:10", "second:20"]) 445 }) 446 447 it("works with async side-effects", async () => { 448 let observed: number | null = null 449 const result = await runPromise( 450 pipe( 451 succeed(42), 452 tapEffect((a: number) => 453 fromPromise(() => 454 Promise.resolve().then(() => { 455 observed = a 456 }), 457 ), 458 ), 459 ), 460 ) 461 expect(result).toBe(42) 462 expect(observed as unknown as number).toBe(42) 463 }) 464 }) 465 466 describe("tapError (effectful)", () => { 467 it("on failure, side-effect Eff runs and original error re-propagates", async () => { 468 let observed: string | null = null 469 const exit = await runPromiseExit( 470 pipe( 471 fail("original-error") as Eff<number, string, unknown>, 472 tapError((e: string) => 473 sync(() => { 474 observed = e 475 }), 476 ), 477 ), 478 ) 479 expect(Exit.isFailure(exit)).toBe(true) 480 if (Exit.isFailure(exit)) expect(exit.error).toBe("original-error") 481 expect(observed as unknown as string).toBe("original-error") 482 }) 483 484 it("on success, side-effect Eff does NOT run and value passes through", async () => { 485 let called = false 486 const result = await runPromise( 487 pipe( 488 succeed(99), 489 tapError((_e: string) => 490 sync(() => { 491 called = true 492 }), 493 ), 494 ), 495 ) 496 expect(result).toBe(99) 497 expect(called).toBe(false) 498 }) 499 500 it("when side-effect Eff fails, that error propagates", async () => { 501 const exit = await runPromiseExit( 502 pipe( 503 fail("original") as Eff<number, string, unknown>, 504 tapError((_e: string) => fail("side-effect-error")), 505 ), 506 ) 507 expect(Exit.isFailure(exit)).toBe(true) 508 if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 509 }) 510 }) 511 512 describe("tapBoth", () => { 513 it("on success, onSuccess runs and onFailure does NOT run", async () => { 514 let successCalled = false 515 let failureCalled = false 516 const result = await runPromise( 517 pipe( 518 succeed(7), 519 tapBoth({ 520 onSuccess: (_a: number) => 521 sync(() => { 522 successCalled = true 523 }), 524 onFailure: (_e: string) => 525 sync(() => { 526 failureCalled = true 527 }), 528 }), 529 ), 530 ) 531 expect(result).toBe(7) 532 expect(successCalled).toBe(true) 533 expect(failureCalled).toBe(false) 534 }) 535 536 it("on failure, onFailure runs and onSuccess does NOT run", async () => { 537 let successCalled = false 538 let failureCalled = false 539 const exit = await runPromiseExit( 540 pipe( 541 fail("err") as Eff<number, string, unknown>, 542 tapBoth({ 543 onSuccess: (_a: number) => 544 sync(() => { 545 successCalled = true 546 }), 547 onFailure: (_e: string) => 548 sync(() => { 549 failureCalled = true 550 }), 551 }), 552 ), 553 ) 554 expect(Exit.isFailure(exit)).toBe(true) 555 expect(successCalled).toBe(false) 556 expect(failureCalled).toBe(true) 557 }) 558 559 it("original value is preserved through onSuccess tap", async () => { 560 const result = await runPromise( 561 pipe( 562 succeed(55), 563 tapBoth({ 564 onSuccess: (_a: number) => succeed("discarded"), 565 onFailure: (_e: string) => succeed("discarded"), 566 }), 567 ), 568 ) 569 expect(result).toBe(55) 570 }) 571 572 it("original error is preserved through onFailure tap", async () => { 573 const exit = await runPromiseExit( 574 pipe( 575 fail("original-err") as Eff<number, string, unknown>, 576 tapBoth({ 577 onSuccess: (_a: number) => succeed("discarded"), 578 onFailure: (_e: string) => succeed("discarded"), 579 }), 580 ), 581 ) 582 expect(Exit.isFailure(exit)).toBe(true) 583 if (Exit.isFailure(exit)) expect(exit.error).toBe("original-err") 584 }) 585 586 it("when onSuccess side-effect fails, that error propagates", async () => { 587 const exit = await runPromiseExit( 588 pipe( 589 succeed(42), 590 tapBoth({ 591 onSuccess: (_a: number) => fail("side-effect-error"), 592 onFailure: (_e: string) => succeed(undefined), 593 }), 594 ), 595 ) 596 expect(Exit.isFailure(exit)).toBe(true) 597 if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 598 }) 599 600 it("when onFailure side-effect fails, that error propagates", async () => { 601 const exit = await runPromiseExit( 602 pipe( 603 fail("original") as Eff<number, string, unknown>, 604 tapBoth({ 605 onSuccess: (_a: number) => succeed(undefined), 606 onFailure: (_e: string) => fail("side-effect-error"), 607 }), 608 ), 609 ) 610 expect(Exit.isFailure(exit)).toBe(true) 611 if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 612 }) 613 }) 614 615 describe("effectful tap - composability", () => { 616 it("pipe with tapEffect, tapError, and mapEff works end-to-end on success", async () => { 617 const log: string[] = [] 618 const result = await runPromise( 619 pipe( 620 succeed(5), 621 tapEffect((n: number) => 622 sync(() => { 623 log.push(`tap:${n}`) 624 }), 625 ), 626 tapError((_e: string) => 627 sync(() => { 628 log.push("tapError:called") 629 }), 630 ), 631 mapEff((n: number) => n * 3), 632 ), 633 ) 634 expect(result).toBe(15) 635 expect(log).toEqual(["tap:5"]) 636 }) 637 638 it("pipe with tapEffect, tapError, and mapEff works end-to-end on failure", async () => { 639 const log: string[] = [] 640 const exit = await runPromiseExit( 641 pipe( 642 fail("err") as Eff<number, string, unknown>, 643 tapEffect((n: number) => 644 sync(() => { 645 log.push(`tap:${n}`) 646 }), 647 ), 648 tapError((e: string) => 649 sync(() => { 650 log.push(`tapError:${e}`) 651 }), 652 ), 653 mapEff((n: number) => n * 3), 654 ), 655 ) 656 expect(Exit.isFailure(exit)).toBe(true) 657 if (Exit.isFailure(exit)) expect(exit.error).toBe("err") 658 expect(log).toEqual(["tapError:err"]) 659 }) 660 661 it("tapEffect with access() and provide() demonstrates requirement widening", async () => { 662 type Logger = { log: (msg: string) => void } 663 const messages: string[] = [] 664 const logger: Logger = { log: (msg) => messages.push(msg) } 665 666 const result = await runPromise( 667 pipe( 668 succeed(42), 669 tapEffect((n: number) => 670 access((env: Logger) => env.log(`value:${n}`)), 671 ), 672 provide<Logger>(logger), 673 ), 674 ) 675 expect(result).toBe(42) 676 expect(messages).toEqual(["value:42"]) 677 }) 678 679 it("tapBoth in a pipeline preserves value through multiple taps", async () => { 680 const log: string[] = [] 681 const result = await runPromise( 682 pipe( 683 succeed(3), 684 tapBoth({ 685 onSuccess: (n: number) => 686 sync(() => { 687 log.push(`both1:${n}`) 688 }), 689 onFailure: (_e: string) => sync(() => {}), 690 }), 691 tapEffect((n: number) => 692 sync(() => { 693 log.push(`tap:${n}`) 694 }), 695 ), 696 tapBoth({ 697 onSuccess: (n: number) => 698 sync(() => { 699 log.push(`both2:${n}`) 700 }), 701 onFailure: (_e: string) => sync(() => {}), 702 }), 703 ), 704 ) 705 expect(result).toBe(3) 706 expect(log).toEqual(["both1:3", "tap:3", "both2:3"]) 707 }) 708 }) 709 710 describe("retry - edge cases", () => { 711 it("retry(0) executes once on success", async () => { 712 let attempts = 0 713 const eff = sync(() => { 714 attempts++ 715 return 42 716 }) 717 const result = await runPromise(pipe(eff, retry(0))) 718 expect(result).toBe(42) 719 expect(attempts).toBe(1) 720 }) 721 722 it("retry(0) propagates error without retrying", async () => { 723 let attempts = 0 724 const exit = await runPromiseExit( 725 pipe( 726 attempt(() => { 727 attempts++ 728 throw "boom" 729 }), 730 retry(0), 731 ), 732 ) 733 expect(Exit.isFailure(exit)).toBe(true) 734 expect(attempts).toBe(1) 735 }) 736 737 it("retry(3) runs initial attempt plus 3 retries when all fail", async () => { 738 let attempts = 0 739 const alwaysFail: Eff<number, string, unknown> = sync(() => { 740 attempts++ 741 return undefined as unknown as number 742 }) 743 // Use attempt to make it fail 744 const failing: Eff<number, string, unknown> = flatMap( 745 () => fail("err") as Eff<number, string, unknown>, 746 )(alwaysFail) 747 const exit = await runPromiseExit(pipe(failing, retry(3))) 748 expect(Exit.isFailure(exit)).toBe(true) 749 expect(attempts).toBe(4) 750 }) 751 }) 752 753 describe("repeatEff - edge cases", () => { 754 it("repeatEff with negative count executes once", async () => { 755 let count = 0 756 const eff = sync(() => { 757 count++ 758 return count 759 }) 760 const result = await runPromise(pipe(eff, repeatEff(-5))) 761 expect(count).toBe(1) 762 expect(result).toBe(1) 763 }) 764 }) 765 766 describe("timeout - edge cases", () => { 767 it("timeout(0) fires immediately when effect is slow", async () => { 768 const result = await runPromise(pipe(sleep(1000), timeout(0))) 769 expect(result).toBeNull() 770 }) 771 772 it("timeout on sync effect returns value before timeout fires", async () => { 773 const result = await runPromise(pipe(succeed(42), timeout(100))) 774 expect(result).toBe(42) 775 }) 776 }) 777 778 describe("bracket - edge cases", () => { 779 it("bracket release that throws propagates as uncaught error", async () => { 780 // release is typed Eff<void, never, R>, but sync(() => { throw }) causes a runtime throw. 781 // The fiber runtime will surface this as a rejection. 782 const released: string[] = [] 783 const run = runPromiseExit( 784 bracket( 785 succeed("res"), 786 (_r) => 787 sync(() => { 788 released.push("before-throw") 789 throw new Error("release-boom") 790 }) as unknown as Eff<void, never, unknown>, 791 (_r) => succeed(1), 792 ), 793 ) 794 // We expect either the promise to reject or exit to be failure due to the throw 795 let didThrow = false 796 try { 797 const exit = await run 798 // If runtime catches it, it should be a failure 799 if (Exit.isFailure(exit)) didThrow = true 800 } catch { 801 didThrow = true 802 } 803 expect(didThrow).toBe(true) 804 expect(released).toContain("before-throw") 805 }) 806 }) 807 808 describe("yieldNow", () => { 809 it("completes successfully after yielding", async () => { 810 const result = await runPromise( 811 pipe( 812 yieldNow, 813 flatMap(() => succeed(42)), 814 ), 815 ) 816 expect(result).toBe(42) 817 }) 818 }) 819 820 describe("fiberId", () => { 821 it("returns a FiberId with numeric id", async () => { 822 const fid = await runPromise(fiberId) 823 expect(fid._tag).toBe("FiberId") 824 expect(typeof fid.id).toBe("number") 825 }) 826 }) 827})