An educational pure functional programming library in TypeScript
2
fork

Configure Feed

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

Add effectful tap combinators (tapEffect, tapError, tapBoth) to the Effect system

+385
+1
.gitignore
··· 36 36 37 37 # Local documentation (not committed) 38 38 *.local.md 39 + .rnd
+97
src/effect/combinators.ts
··· 84 84 ), 85 85 ) 86 86 87 + // === TapEffect - effectful success tap === 88 + 89 + /** 90 + * Observe a success value with an effectful side-effect, then return the original value. 91 + * The side-effect's result is discarded; only its error channel can influence the outcome. 92 + * Named `tapEffect` to distinguish from the plain-value `tap` in prelude/compose. 93 + * 94 + * @example 95 + * ```typescript 96 + * pipe( 97 + * loadUser(id), 98 + * tapEffect((u) => logEvent("user_loaded", { id: u.id })), 99 + * mapEff((u) => u.name), 100 + * ) 101 + * ``` 102 + */ 103 + export const tapEffect = 104 + <A, E2, R2>(f: (a: A) => Eff<unknown, E2, R2>) => 105 + <E, R>(eff: Eff<A, E, R>): Eff<A, E | E2, R | R2> => 106 + pipe( 107 + eff as Eff<A, E | E2, R | R2>, 108 + flatMap((a: A) => 109 + pipe( 110 + f(a) as Eff<unknown, E | E2, R | R2>, 111 + mapEff(() => a), 112 + ), 113 + ), 114 + ) 115 + 116 + // === TapError - effectful error tap === 117 + 118 + /** 119 + * Observe an error with an effectful side-effect, then re-propagate the original error. 120 + * The side-effect's result is discarded; a failure in it adds to the error union. 121 + * 122 + * @example 123 + * ```typescript 124 + * pipe( 125 + * loadUser(id), 126 + * tapError((e) => logEvent("load_failed", { error: e })), 127 + * ) 128 + * ``` 129 + */ 130 + export const tapError = 131 + <E, E2, R2>(f: (e: E) => Eff<unknown, E2, R2>) => 132 + <A, R>(eff: Eff<A, E, R>): Eff<A, E | E2, R | R2> => 133 + pipe( 134 + eff as Eff<A, E | E2, R | R2>, 135 + foldEff( 136 + (e: E | E2) => 137 + pipe( 138 + f(e as E) as Eff<unknown, E | E2, R | R2>, 139 + flatMap(() => fail(e) as Eff<A, E | E2, R | R2>), 140 + ), 141 + (a: A) => succeed(a) as Eff<A, E | E2, R | R2>, 142 + ), 143 + ) 144 + 145 + // === TapBoth - effectful tap on both success and failure === 146 + 147 + /** 148 + * Observe both success and failure with effectful side-effects. 149 + * The original value or error is always preserved after the side-effect runs. 150 + * 151 + * @example 152 + * ```typescript 153 + * pipe( 154 + * loadUser(id), 155 + * tapBoth({ 156 + * onSuccess: (u) => logEvent("loaded", { id: u.id }), 157 + * onFailure: (e) => logEvent("failed", { error: e }), 158 + * }), 159 + * ) 160 + * ``` 161 + */ 162 + export const tapBoth = 163 + <A, E, E2, R2>(handlers: { 164 + readonly onSuccess: (a: A) => Eff<unknown, E2, R2> 165 + readonly onFailure: (e: E) => Eff<unknown, E2, R2> 166 + }) => 167 + <R>(eff: Eff<A, E, R>): Eff<A, E | E2, R | R2> => 168 + pipe( 169 + eff as Eff<A, E | E2, R | R2>, 170 + foldEff( 171 + (e: E | E2) => 172 + pipe( 173 + handlers.onFailure(e as E) as Eff<unknown, E | E2, R | R2>, 174 + flatMap(() => fail(e) as Eff<A, E | E2, R | R2>), 175 + ), 176 + (a: A) => 177 + pipe( 178 + handlers.onSuccess(a) as Eff<unknown, E | E2, R | R2>, 179 + mapEff(() => a), 180 + ), 181 + ), 182 + ) 183 + 87 184 // === Join === 88 185 89 186 export const join = <A, E>(fiber: Fiber<A, E>): Eff<A, E, unknown> =>
+287
tests/effect-basic.test.ts
··· 18 18 runPromiseExit, 19 19 succeed, 20 20 sync, 21 + tapBoth, 22 + tapEffect, 21 23 tapErr, 24 + tapError, 22 25 } from "../src/index" 23 26 24 27 describe("Effect - Basic Operations", () => { ··· 310 313 expect(Exit.isFailure(exit)).toBe(true) 311 314 if (Exit.isFailure(exit)) expect(exit.error).toBe("acquire-fail") 312 315 expect(log).toEqual([]) 316 + }) 317 + }) 318 + 319 + describe("tapEffect (effectful)", () => { 320 + it("on success, side-effect Eff runs and original value is returned", async () => { 321 + let observed: number | null = null 322 + const result = await runPromise( 323 + pipe( 324 + succeed(42), 325 + tapEffect((a: number) => 326 + sync(() => { 327 + observed = a 328 + }), 329 + ), 330 + ), 331 + ) 332 + expect(result).toBe(42) 333 + expect(observed as unknown as number).toBe(42) 334 + }) 335 + 336 + it("on failure, side-effect Eff does NOT run and error propagates unchanged", async () => { 337 + let called = false 338 + const exit = await runPromiseExit( 339 + pipe( 340 + fail("boom") as Eff<number, string, unknown>, 341 + tapEffect((_a: number) => 342 + sync(() => { 343 + called = true 344 + }), 345 + ), 346 + ), 347 + ) 348 + expect(Exit.isFailure(exit)).toBe(true) 349 + if (Exit.isFailure(exit)) expect(exit.error).toBe("boom") 350 + expect(called).toBe(false) 351 + }) 352 + 353 + it("when side-effect Eff fails, that error propagates", async () => { 354 + const exit = await runPromiseExit( 355 + pipe( 356 + succeed(42), 357 + tapEffect((_a: number) => fail("side-effect-error")), 358 + ), 359 + ) 360 + expect(Exit.isFailure(exit)).toBe(true) 361 + if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 362 + }) 363 + 364 + it("works with pipe() in a multi-step chain", async () => { 365 + const log: string[] = [] 366 + const result = await runPromise( 367 + pipe( 368 + succeed(10), 369 + tapEffect((n: number) => 370 + sync(() => { 371 + log.push(`first:${n}`) 372 + }), 373 + ), 374 + mapEff((n: number) => n * 2), 375 + tapEffect((n: number) => 376 + sync(() => { 377 + log.push(`second:${n}`) 378 + }), 379 + ), 380 + ), 381 + ) 382 + expect(result).toBe(20) 383 + expect(log).toEqual(["first:10", "second:20"]) 384 + }) 385 + }) 386 + 387 + describe("tapError (effectful)", () => { 388 + it("on failure, side-effect Eff runs and original error re-propagates", async () => { 389 + let observed: string | null = null 390 + const exit = await runPromiseExit( 391 + pipe( 392 + fail("original-error") as Eff<number, string, unknown>, 393 + tapError((e: string) => 394 + sync(() => { 395 + observed = e 396 + }), 397 + ), 398 + ), 399 + ) 400 + expect(Exit.isFailure(exit)).toBe(true) 401 + if (Exit.isFailure(exit)) expect(exit.error).toBe("original-error") 402 + expect(observed as unknown as string).toBe("original-error") 403 + }) 404 + 405 + it("on success, side-effect Eff does NOT run and value passes through", async () => { 406 + let called = false 407 + const result = await runPromise( 408 + pipe( 409 + succeed(99), 410 + tapError((_e: string) => 411 + sync(() => { 412 + called = true 413 + }), 414 + ), 415 + ), 416 + ) 417 + expect(result).toBe(99) 418 + expect(called).toBe(false) 419 + }) 420 + 421 + it("when side-effect Eff fails, that error propagates", async () => { 422 + const exit = await runPromiseExit( 423 + pipe( 424 + fail("original") as Eff<number, string, unknown>, 425 + tapError((_e: string) => fail("side-effect-error")), 426 + ), 427 + ) 428 + expect(Exit.isFailure(exit)).toBe(true) 429 + if (Exit.isFailure(exit)) expect(exit.error).toBe("side-effect-error") 430 + }) 431 + }) 432 + 433 + describe("tapBoth", () => { 434 + it("on success, onSuccess runs and onFailure does NOT run", async () => { 435 + let successCalled = false 436 + let failureCalled = false 437 + const result = await runPromise( 438 + pipe( 439 + succeed(7), 440 + tapBoth({ 441 + onSuccess: (_a: number) => 442 + sync(() => { 443 + successCalled = true 444 + }), 445 + onFailure: (_e: string) => 446 + sync(() => { 447 + failureCalled = true 448 + }), 449 + }), 450 + ), 451 + ) 452 + expect(result).toBe(7) 453 + expect(successCalled).toBe(true) 454 + expect(failureCalled).toBe(false) 455 + }) 456 + 457 + it("on failure, onFailure runs and onSuccess does NOT run", async () => { 458 + let successCalled = false 459 + let failureCalled = false 460 + const exit = await runPromiseExit( 461 + pipe( 462 + fail("err") as Eff<number, string, unknown>, 463 + tapBoth({ 464 + onSuccess: (_a: number) => 465 + sync(() => { 466 + successCalled = true 467 + }), 468 + onFailure: (_e: string) => 469 + sync(() => { 470 + failureCalled = true 471 + }), 472 + }), 473 + ), 474 + ) 475 + expect(Exit.isFailure(exit)).toBe(true) 476 + expect(successCalled).toBe(false) 477 + expect(failureCalled).toBe(true) 478 + }) 479 + 480 + it("original value is preserved through onSuccess tap", async () => { 481 + const result = await runPromise( 482 + pipe( 483 + succeed(55), 484 + tapBoth({ 485 + onSuccess: (_a: number) => succeed("discarded"), 486 + onFailure: (_e: string) => succeed("discarded"), 487 + }), 488 + ), 489 + ) 490 + expect(result).toBe(55) 491 + }) 492 + 493 + it("original error is preserved through onFailure tap", async () => { 494 + const exit = await runPromiseExit( 495 + pipe( 496 + fail("original-err") as Eff<number, string, unknown>, 497 + tapBoth({ 498 + onSuccess: (_a: number) => succeed("discarded"), 499 + onFailure: (_e: string) => succeed("discarded"), 500 + }), 501 + ), 502 + ) 503 + expect(Exit.isFailure(exit)).toBe(true) 504 + if (Exit.isFailure(exit)) expect(exit.error).toBe("original-err") 505 + }) 506 + }) 507 + 508 + describe("effectful tap - composability", () => { 509 + it("pipe with tapEffect, tapError, and mapEff works end-to-end on success", async () => { 510 + const log: string[] = [] 511 + const result = await runPromise( 512 + pipe( 513 + succeed(5), 514 + tapEffect((n: number) => 515 + sync(() => { 516 + log.push(`tap:${n}`) 517 + }), 518 + ), 519 + tapError((_e: string) => 520 + sync(() => { 521 + log.push("tapError:called") 522 + }), 523 + ), 524 + mapEff((n: number) => n * 3), 525 + ), 526 + ) 527 + expect(result).toBe(15) 528 + expect(log).toEqual(["tap:5"]) 529 + }) 530 + 531 + it("pipe with tapEffect, tapError, and mapEff works end-to-end on failure", async () => { 532 + const log: string[] = [] 533 + const exit = await runPromiseExit( 534 + pipe( 535 + fail("err") as Eff<number, string, unknown>, 536 + tapEffect((n: number) => 537 + sync(() => { 538 + log.push(`tap:${n}`) 539 + }), 540 + ), 541 + tapError((e: string) => 542 + sync(() => { 543 + log.push(`tapError:${e}`) 544 + }), 545 + ), 546 + mapEff((n: number) => n * 3), 547 + ), 548 + ) 549 + expect(Exit.isFailure(exit)).toBe(true) 550 + if (Exit.isFailure(exit)) expect(exit.error).toBe("err") 551 + expect(log).toEqual(["tapError:err"]) 552 + }) 553 + 554 + it("tapEffect with access() and provide() demonstrates requirement widening", async () => { 555 + type Logger = { log: (msg: string) => void } 556 + const messages: string[] = [] 557 + const logger: Logger = { log: (msg) => messages.push(msg) } 558 + 559 + const result = await runPromise( 560 + pipe( 561 + succeed(42), 562 + tapEffect((n: number) => 563 + access((env: Logger) => env.log(`value:${n}`)), 564 + ), 565 + provide<Logger>(logger), 566 + ), 567 + ) 568 + expect(result).toBe(42) 569 + expect(messages).toEqual(["value:42"]) 570 + }) 571 + 572 + it("tapBoth in a pipeline preserves value through multiple taps", async () => { 573 + const log: string[] = [] 574 + const result = await runPromise( 575 + pipe( 576 + succeed(3), 577 + tapBoth({ 578 + onSuccess: (n: number) => 579 + sync(() => { 580 + log.push(`both1:${n}`) 581 + }), 582 + onFailure: (_e: string) => sync(() => {}), 583 + }), 584 + tapEffect((n: number) => 585 + sync(() => { 586 + log.push(`tap:${n}`) 587 + }), 588 + ), 589 + tapBoth({ 590 + onSuccess: (n: number) => 591 + sync(() => { 592 + log.push(`both2:${n}`) 593 + }), 594 + onFailure: (_e: string) => sync(() => {}), 595 + }), 596 + ), 597 + ) 598 + expect(result).toBe(3) 599 + expect(log).toEqual(["both1:3", "tap:3", "both2:3"]) 313 600 }) 314 601 }) 315 602 })