Skip to content

Commit

Permalink
Schedule: fix unsafe tapOutput signature
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti committed Feb 11, 2025
1 parent 5808fbc commit b812a79
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 18 deletions.
33 changes: 33 additions & 0 deletions .changeset/gold-jobs-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"effect": patch
---

Schedule: fix unsafe `tapOutput` signature.

Previously, `tapOutput` allowed using an output type that wasn't properly inferred, leading to potential runtime errors. Now, TypeScript correctly detects mismatches at compile time, preventing unexpected crashes.

**Before (Unsafe, Causes Runtime Error)**

```ts
import { Effect, Schedule, Console } from "effect"

const schedule = Schedule.once.pipe(
Schedule.as<number | string>(1),
Schedule.tapOutput((s: string) => Console.log(s.trim())) // ❌ Runtime error
)

Effect.runPromise(Effect.void.pipe(Effect.schedule(schedule)))
// throws: TypeError: s.trim is not a function
```

**After (Safe, Catches Type Error at Compile Time)**

```ts
import { Console, Schedule } from "effect"

const schedule = Schedule.once.pipe(
Schedule.as<number | string>(1),
// ✅ Type Error: Type 'number' is not assignable to type 'string'
Schedule.tapOutput((s: string) => Console.log(s.trim()))
)
```
33 changes: 33 additions & 0 deletions packages/effect/dtslint/Schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Console, Schedule } from "effect"

// -------------------------------------------------------------------------------------
// tapOutput
// -------------------------------------------------------------------------------------

// $ExpectType Schedule<string | number, unknown, never>
Schedule.once.pipe(
Schedule.as<number | string>(1),
Schedule.tapOutput((
x // $ExpectType string | number
) => Console.log(x))
)

// The callback should not affect the type of the output (`number`)
// $ExpectType Schedule<number, unknown, never>
Schedule.once.pipe(
Schedule.as(1),
Schedule.tapOutput((x: string | number) => Console.log(x))
)
// $ExpectType Schedule<number, unknown, never>
Schedule.tapOutput(
Schedule.once.pipe(
Schedule.as(1)
),
(x: string | number) => Console.log(x)
)

Schedule.once.pipe(
Schedule.as<number | string>(1),
// @ts-expect-error
Schedule.tapOutput((s: string) => Console.log(s.trim()))
)
15 changes: 8 additions & 7 deletions packages/effect/src/Schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,16 +1763,17 @@ export const spaced: (duration: Duration.DurationInput) => Schedule<number> = in
export const stop: Schedule<void> = internal.stop

/**
* Returns a schedule that runs once and produces the specified constant value.
* Returns a schedule that recurs indefinitely, always producing the specified
* constant value.
*
* @since 2.0.0
* @category Constructors
*/
export const succeed: <A>(value: A) => Schedule<A> = internal.succeed

/**
* Returns a schedule that runs once, evaluating the given function to produce a
* constant value.
* Returns a schedule that recurs indefinitely, evaluating the given function to
* produce a constant value.
*
* @category Constructors
* @since 2.0.0
Expand Down Expand Up @@ -1816,12 +1817,12 @@ export const tapInput: {
* @category Tapping
*/
export const tapOutput: {
<XO extends Out, X, R2, Out>(
f: (out: XO) => Effect.Effect<X, never, R2>
<X, R2, Out>(
f: (out: Types.NoInfer<Out>) => Effect.Effect<X, never, R2>
): <In, R>(self: Schedule<Out, In, R>) => Schedule<Out, In, R2 | R>
<Out, In, R, XO extends Out, X, R2>(
<Out, In, R, X, R2>(
self: Schedule<Out, In, R>,
f: (out: XO) => Effect.Effect<X, never, R2>
f: (out: Out) => Effect.Effect<X, never, R2>
): Schedule<Out, In, R | R2>
} = internal.tapOutput

Expand Down
28 changes: 17 additions & 11 deletions packages/effect/src/internal/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,19 +1362,25 @@ export const tapInput = dual<

/** @internal */
export const tapOutput = dual<
<XO extends Out, X, R2, Out>(
f: (out: XO) => Effect.Effect<X, never, R2>
) => <In, R>(self: Schedule.Schedule<Out, In, R>) => Schedule.Schedule<Out, In, R | R2>,
<Out, In, R, XO extends Out, X, R2>(
<X, R2, Out>(
f: (out: Types.NoInfer<Out>) => Effect.Effect<X, never, R2>
) => <In, R>(self: Schedule.Schedule<Out, In, R>) => Schedule.Schedule<Out, In, R2 | R>,
<Out, In, R, X, R2>(
self: Schedule.Schedule<Out, In, R>,
f: (out: XO) => Effect.Effect<X, never, R2>
f: (out: Out) => Effect.Effect<X, never, R2>
) => Schedule.Schedule<Out, In, R | R2>
>(2, (self, f) =>
makeWithState(self.initial, (now, input, state) =>
core.tap(
self.step(now, input, state),
([, out]) => f(out as any)
)))
>(
2,
<Out, In, R, X, R2>(
self: Schedule.Schedule<Out, In, R>,
f: (out: Out) => Effect.Effect<X, never, R2>
): Schedule.Schedule<Out, In, R | R2> =>
makeWithState(self.initial, (now, input, state) =>
core.tap(
self.step(now, input, state),
([, out]) => f(out)
))
)

/** @internal */
export const unfold = <A>(initial: A, f: (a: A) => A): Schedule.Schedule<A> =>
Expand Down
14 changes: 14 additions & 0 deletions packages/effect/test/Schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,20 @@ describe("Schedule", () => {
)
deepStrictEqual(exit, Exit.die(exception))
}))
it.effect("tapOutput", () =>
Effect.gen(function*() {
const log: Array<number | string> = []
const schedule = Schedule.once.pipe(
Schedule.as<number | string>(1),
Schedule.tapOutput((x) =>
Effect.sync(() => {
log.push(x)
})
)
)
yield* Effect.void.pipe(Effect.schedule(schedule))
deepStrictEqual(log, [1, 1])
}))
})
})

Expand Down

0 comments on commit b812a79

Please sign in to comment.