An educational pure functional programming library in TypeScript
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})