Skip to content

Commit ec49b74

Browse files
committed
✨ feat: Add Effected#tap(handler)
1. Add `Effected#tap(handler)` for side effects without altering return value.
1 parent bcb9f66 commit ec49b74

5 files changed

Lines changed: 205 additions & 32 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,39 @@ const safeDivide2 = (a: number, b: number): Effected<never, Option<number>> =>
748748

749749
Now, running `safeDivide2(1, 0).runSync()` will return `none`, while `safeDivide2(1, 2).runSync()` will return `some(0.5)`.
750750

751+
Besides `.map(handler)`, the `.tap(handler)` method offers a useful alternative when you want to execute side effects without altering the return value. Unlike `.map()`, `.tap()` ignores the return value of the handler function, ensuring the original value is preserved. This makes it ideal for operations like logging, where the action doesn’t modify the main data flow.
752+
753+
For instance, you can use `.tap()` to simulate a `defer` effect similar to Go’s `defer` statement:
754+
755+
```typescript
756+
type Defer = Effect<"defer", [fn: () => void], void>;
757+
const defer: EffectFactory<Defer> = effect("defer");
758+
759+
const deferHandler = defineHandlerFor<Defer>().with((effected) => {
760+
const deferredActions: Array<() => void> = [];
761+
762+
return effected
763+
.resume("defer", (fn) => {
764+
deferredActions.push(fn);
765+
})
766+
.tap(() => {
767+
deferredActions.forEach((fn) => fn());
768+
});
769+
});
770+
771+
const program = effected(function* () {
772+
yield* defer(() => console.log("Deferred action"));
773+
console.log("Normal action");
774+
}).with(deferHandler);
775+
```
776+
777+
When you run `program.runSync()`, you’ll see the following output:
778+
779+
```text
780+
Normal action
781+
Deferred action
782+
```
783+
751784
### Handling multiple effects in one handler
752785

753786
Imagine we have the following setup:

src/README.example.proof.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -331,28 +331,54 @@ test("Handling effects with another effected program", () => {
331331
});
332332

333333
test("Handling return values", () => {
334-
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
335-
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
334+
{
335+
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
336+
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
336337

337-
const safeDivide = (a: number, b: number) =>
338-
effected(function* () {
339-
if (b === 0) return yield* raise("Division by zero");
340-
return a / b;
341-
});
338+
const safeDivide = (a: number, b: number) =>
339+
effected(function* () {
340+
if (b === 0) return yield* raise("Division by zero");
341+
return a / b;
342+
});
342343

343-
expect(safeDivide).to(equal<(a: number, b: number) => Effected<Raise, number>>);
344+
expect(safeDivide).to(equal<(a: number, b: number) => Effected<Raise, number>>);
344345

345-
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
346+
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
346347

347-
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
348-
const none: Option<never> = { kind: "none" };
348+
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
349+
const none: Option<never> = { kind: "none" };
349350

350-
const safeDivide2 = (a: number, b: number) =>
351-
safeDivide(a, b)
352-
.map((value) => some(value))
353-
.terminate("raise", () => none);
351+
const safeDivide2 = (a: number, b: number) =>
352+
safeDivide(a, b)
353+
.map((value) => some(value))
354+
.terminate("raise", () => none);
354355

355-
expect(safeDivide2).to(equal<(a: number, b: number) => Effected<never, Option<number>>>);
356+
expect(safeDivide2).to(equal<(a: number, b: number) => Effected<never, Option<number>>>);
357+
}
358+
359+
{
360+
type Defer = Effect<"defer", [fn: () => void], void>;
361+
const defer: EffectFactory<Defer> = effect("defer");
362+
363+
const deferHandler = defineHandlerFor<Defer>().with((effected) => {
364+
const deferredActions: Array<() => void> = [];
365+
366+
return effected
367+
.resume("defer", (fn) => {
368+
deferredActions.push(fn);
369+
})
370+
.tap(() => {
371+
deferredActions.forEach((fn) => fn());
372+
});
373+
});
374+
375+
const program = effected(function* () {
376+
yield* defer(() => console.log("Deferred action"));
377+
console.log("Normal action");
378+
}).with(deferHandler);
379+
380+
expect(program).to(equal<Effected<never, void>>);
381+
}
356382
});
357383

358384
test("Handling multiple effects in one handler", () => {

src/README.example.spec.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -624,27 +624,65 @@ test("Handling effects with another effected program", () => {
624624
});
625625

626626
test("Handling return values", () => {
627-
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
628-
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
627+
{
628+
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
629+
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
629630

630-
const safeDivide = (a: number, b: number): Effected<Raise, number> =>
631-
effected(function* () {
632-
if (b === 0) return yield* raise("Division by zero");
633-
return a / b;
634-
});
631+
const safeDivide = (a: number, b: number): Effected<Raise, number> =>
632+
effected(function* () {
633+
if (b === 0) return yield* raise("Division by zero");
634+
return a / b;
635+
});
635636

636-
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
637+
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
638+
639+
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
640+
const none: Option<never> = { kind: "none" };
641+
642+
const safeDivide2 = (a: number, b: number): Effected<never, Option<number>> =>
643+
safeDivide(a, b)
644+
.map((value) => some(value))
645+
.terminate("raise", () => none);
637646

638-
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
639-
const none: Option<never> = { kind: "none" };
647+
expect(safeDivide2(1, 0).runSync()).toEqual(none);
648+
expect(safeDivide2(1, 2).runSync()).toEqual(some(0.5));
649+
}
650+
651+
{
652+
type Defer = Effect<"defer", [fn: () => void], void>;
653+
const defer: EffectFactory<Defer> = effect("defer");
640654

641-
const safeDivide2 = (a: number, b: number): Effected<never, Option<number>> =>
642-
safeDivide(a, b)
643-
.map((value) => some(value))
644-
.terminate("raise", () => none);
655+
const deferHandler = defineHandlerFor<Defer>().with((effected) => {
656+
const deferredActions: Array<() => void> = [];
645657

646-
expect(safeDivide2(1, 0).runSync()).toEqual(none);
647-
expect(safeDivide2(1, 2).runSync()).toEqual(some(0.5));
658+
return effected
659+
.resume("defer", (fn) => {
660+
deferredActions.push(fn);
661+
})
662+
.tap(() => {
663+
deferredActions.forEach((fn) => fn());
664+
});
665+
});
666+
667+
const program = effected(function* () {
668+
yield* defer(() => console.log("Deferred action"));
669+
console.log("Normal action");
670+
}).with(deferHandler);
671+
672+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
673+
program.runSync();
674+
expect(logSpy.mock.calls).toMatchInlineSnapshot(`
675+
[
676+
[
677+
"Normal action",
678+
],
679+
[
680+
"Deferred action",
681+
],
682+
]
683+
`);
684+
logSpy.mockRestore();
685+
}
648686
});
649687

650688
test("Handling multiple effects in one handler", () => {

src/effected.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,49 @@ describe("effected", () => {
621621
});
622622
});
623623

624+
describe("Effected#tap", () => {
625+
it("should run a side effect and return the original value", () => {
626+
const logs: unknown[][] = [];
627+
expect(
628+
Effected.of(42)
629+
.tap((value) => {
630+
logs.push(["tap", value]);
631+
return value + 1;
632+
})
633+
.runSync(),
634+
).toBe(42);
635+
expect(logs).toEqual([["tap", 42]]);
636+
});
637+
638+
it("should run a side effect with other effects", () => {
639+
const logs: unknown[][] = [];
640+
expect(
641+
Effected.of(42)
642+
.tap(function* (value) {
643+
yield* log("tap", value);
644+
return (value + 1) as unknown as void;
645+
})
646+
.resume("log", (...args) => Array.prototype.push.apply(logs, args))
647+
.runSync(),
648+
).toBe(42);
649+
expect(logs).toEqual(["tap", 42]);
650+
651+
logs.length = 0;
652+
expect(
653+
Effected.of(42)
654+
.tap((value) =>
655+
effected(function* () {
656+
yield* log("tap", value);
657+
return (value + 1) as unknown as void;
658+
}),
659+
)
660+
.resume("log", (...args) => Array.prototype.push.apply(logs, args))
661+
.runSync(),
662+
).toBe(42);
663+
expect(logs).toEqual(["tap", 42]);
664+
});
665+
});
666+
624667
describe("Effected#catchAndThrow", () => {
625668
const typeError = error("type");
626669

src/effected.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,29 @@ export class Effected<out E extends Effect, out R> implements Iterable<E, R, unk
670670
});
671671
}
672672

673+
/**
674+
* Tap the return value of the effected program.
675+
* @param handler The function to tap the return value.
676+
* @returns
677+
*
678+
* @since 0.2.1
679+
*/
680+
tap<F extends Effect = never>(handler: (value: R) => Effected<F, void>): Effected<E | F, R>;
681+
tap<F extends Effect = never>(
682+
handler: (value: R) => Generator<F, void, unknown>,
683+
): Effected<E | F, R>;
684+
tap(handler: (value: R) => void): Effected<E, R>;
685+
tap(handler: (value: R) => unknown): Effected<E, unknown> {
686+
return this.map((value) => {
687+
const it = handler(value);
688+
if (!(it instanceof Effected) && !isGenerator(it)) return value;
689+
return (function* () {
690+
yield* it;
691+
return value;
692+
})();
693+
});
694+
}
695+
673696
/**
674697
* Catch an error effect with a handler.
675698
*
@@ -1062,6 +1085,16 @@ interface EffectedDraft<
10621085
<S>(handler: (value: R) => S): EffectedDraft<P, E, S>;
10631086
};
10641087

1088+
readonly tap: {
1089+
<F extends Effect = never>(
1090+
handler: (value: R) => Effected<F, void>,
1091+
): EffectedDraft<P, E | F, R>;
1092+
<F extends Effect = never>(
1093+
handler: (value: R) => Generator<F, void, unknown>,
1094+
): EffectedDraft<P, E | F, R>;
1095+
(handler: (value: R) => void): EffectedDraft<P, E, R>;
1096+
};
1097+
10651098
readonly catch: {
10661099
<Name extends ErrorName<E>, T, F extends Effect = never>(
10671100
effect: Name,

0 commit comments

Comments
 (0)