In this article, I want to talk about schedules in Effect and, for better understanding and fun, implement my own with Promises.
A Schedule
is an abstraction for managing the repetition of effects, determining how many times and at what intervals an operation should be executed or restarted. In tools like Effect
, schedules are often used to handle retry logic in error handling or to simply repeat effects.
In Effect
, schedules consist of the following components:
-
Schedule
: Defines the initial state and a function that, given the current state and input, returns a triple:[new state, output value, decision]
. -
Decision
: Indicates whether to continue execution or terminate, and whether to resume immediately or after a delay. -
Driver
: Essentially a stateful iterator that manages the schedule’s state while exposing only input → output logic externally.
To better understand how this works, I decided to create a simplified model using Promises. Below is a TypeScript example.
interface Schedule<Out, In = unknown, S = any> {
readonly initial: S;
readonly step: (input: In, currentState: S) => [newState: S, Out, Decision];
}
type Decision =
| { tag: "Continue"; delay: 0 | number } // will use 0 for sync execution
| { tag: "Done" };
interface Driver<Out, In, S> {
private state: S;
constructor(readonly schedule: Schedule<Out, In, S>);
next(input: In): Promise<Out>;
}
PoC
To start, let's assemble a Proof of Concept to understand how everything will work together.
Schedule
Schedule
— a regular class with a pipe method for convenience.
class Schedule<out Out, in In = unknown, in out S = any> {
constructor(
readonly initial: S,
readonly step: (input: In, state: S) => [state: S, out: Out, Decision],
) {}
pipe<B>(f: (value: this) => B) {
return f(this);
}
}
Now let's write the simplest Schedule
for generating infinite sequences from a single value — unfold
. (similar to the inverse of Array.reduce
)
const unfold = <A>(initial: A, f: (a: A) => A): Schedule<A, unknown, A> =>
new Schedule<A, unknown, A>(initial, (_, state) => [
f(state), // new state
state, // output value
{ tag: "Continue", delay: 0 }, // immediate continuation
]);
And use it for count
(0-1-2-3-...). We initialize it with the starting value 0
and provide a stepping function:
const count = unfold(0, (x) => x + 1);
Driver
The driver stores the current state returned by the Schedule
and feeds it back on the next iteration. It can reset to its initial state since that information is embedded in the schedule itself. The repeat function loops, calling driver.next
and executing the effect until a terminal signal is received.
class Driver<Out, In, S> {
private state: S;
private readonly schedule: Schedule<Out, In, S>;
constructor(schedule: Schedule<Out, In, S>) {
this.schedule = schedule;
this.state = schedule.initial;
}
async next(input: In) {
const [state, out, decision] = this.schedule.step(input, this.state);
this.state = state;
if (decision.tag === "Done") {
return null;
} else {
await Driver.sleep(decision.delay);
}
return out;
}
private static sleep = (duration: number) =>
new Promise((res) => setTimeout(() => res(void 0), duration));
}
Repeating
So, let's finally write a function to repeat an action according to a schedule. It takes the action and the schedule as arguments, and internally creates a driver to coordinate the execution of the task with the schedule.
const repeat = async <T,>(
task: () => Promise<T>,
schedule: Schedule<any, T>,
) => {
const driver = new Driver(schedule);
while (true) {
const result = await task();
const next = await driver.next(result);
if (next === null) {
return result;
}
}
};
Result
Now, let's put everything together and run it.
const logTask = () => console.log("logTask")
repeat(
logTask(0),
count
);
// "logTask"
// "logTask"
// ... infinity ...
Here we have figured out the mechanism for executing and applying schedules!
Now, let’s dive into composing schedules to create more complex ones from smaller, simpler pieces.
Fibonacci Scheduler
Let’s define a Schedule
that fires five times with Fibonacci sequence intervals: 0, 1, 1, 2, and 3 seconds.
const fibonacci = unfold<[number, number]>(
[0, 1],
([prev, cur]) => [cur, cur + prev]
);
Let’s now write a map
function that transforms a schedule’s output by applying a given function to its results. We will use it on order to extract second member from tuple generated by fibonacci
function.
const map = <Out, Out1>(f: (out: Out) => Out1) =>
<In, S>(schedule: Schedule<Out, In, S>) =>
new Schedule<Out1, In, S>(schedule.initial, (input, state) => {
const [_state, _out, _decision] = schedule.step(input, state);
return [_state, f(_out), _decision]; // f(_out) - mapping
});
Other Combinators
Most other combinators follow a similar structure:
- They return a
new Schedule
- They pass through the
initial
state - They modify the
step
function in some way
For example, the check
takes a Schedule
and a predicate to be applied to its result. If the predicate returns false
, it changes the Decision
of the original schedule to Done
.
const check =
<Out, In>(predicate: (in_: In, out: Out) => boolean) =>
<S,>(schedule: Schedule<Out, In, S>) =>
new Schedule<Out, In, S>(schedule.initial, (input, state) => {
const [_state, _out, _decision] = schedule.step(input, state);
if (_decision.tag === "Continue") {
if (predicate(input, _out)) {
return [_state, _out, _decision] as const;
} else {
return [_state, _out, { tag: "Done" }] as const; // Done
}
}
return [_state, _out, _decision] as const;
});
By combining count
and check
, we can create recurs
— a schedule that will run only a specified number of times.
const recurs = (n: number) => count
.pipe(check((_, out) => out + 1 <= n));
The intersect
combinator takes two schedules and creates a new one by processing their results. It stops if either schedule decides to terminate. If both emit values, the smallest delay is chosen.
const intersect =
<Out, In, S>(scheduleA: Schedule<Out, In, S>) =>
<Out1, In1, S1>(scheduleB: Schedule<Out1, In1, S1>) =>
new Schedule<[Out, Out1], In & In1, [S, S1]>(
[scheduleA.initial, scheduleB.initial] as const,
(input, [stateA, stateB]) => {
const [_stateA, _outA, _decisionA] = scheduleA.step(input, stateA);
const [_stateB, _outB, _decisionB] = scheduleB.step(input, stateB);
let _decision: Decision = { tag: "Done" };
if (_decisionA.tag === "Continue" && _decisionB.tag === "Continue") {
_decision = {
tag: "Continue",
delay:
_decisionA.delay !== 0
? _decisionB.delay !== 0
? Math.min(_decisionA.delay, _decisionB.delay)
: _decisionA.delay
: _decisionB.delay !== 0
? _decisionB.delay
: 0,
};
}
return [[_stateA, _stateB], [_outA, _outB], _decision] as const;
},
);
Great! Now, let’s add time delays to schedules, which is one of the key features of scheduling systems. Actually, this function differs from map
only in that it is focused on a different aspect of the step function - Decision
.
const addDelay =
<Out,>(f: (out: Out, timer: number) => number) =>
<In, S>(schedule: Schedule<Out, In, S>) =>
new Schedule<Out, In, S>(schedule.initial, (input, state) => {
const [_state, _out, _decision] = schedule.step(input, state);
if (_decision.tag === "Continue") {
return [
_state,
_out,
{ tag: "Continue", delay: f(_out, _decision.delay) },
] as const;
}
return [_state, _out, _decision] as const;
});
Let's now put everything together
const delayedFibonacci = fibonacci
.pipe(map((x) => x[1])) // get fibonacci element
.addDelay(x => x * 1000) // and interpreter it as delay in seconds
const fibonacciScheduler = delayedFibonacci
.pipe(intersect(recurs(5))) // take only 5 first steps
Full code in playground - https://tsplay.dev/NVBolN
Description of schedule
Since a schedule is simply a description of timing behavior, swapping out the Driver
changes how it behaves. We could, for example, ignore delays entirely or integrate this into libraries like RxJS
.
The real power of Schedule
is its composability. Even in this basic example, we’ve created a fairly sophisticated schedule from a simple unfold combined with mappings.
Instead of the conclusion
I enjoy digging into the inner workings of features because it deepens my understanding not just of specific technologies but also of transferable design patterns and problem-solving techniques. Writing this schedule from scratch wouldn't have been easy without exploring the internals — but now it's clear that everything revolves around a step function.
Top comments (0)