Skip to content

Future patterns

Promises and actors are two different concurrency models living in the same TypeScript runtime. An actor’s onReceive can await a promise, but doing so blocks the actor’s mailbox until the promise settles. The framework’s future patterns are a small set of helpers for crossing the bridge without blocking.

HelperRole
pipeToSend a promise’s eventual result as a message to an actor.
afterWait delayMs, then run a factory; cancellable.
Success<T> / FailureTagged envelope types — pipeTo wraps results in these by default.

Together they let you initiate async work, keep onReceive non-blocking, and receive the result as another message.

class Slow extends Actor<...> {
override async onReceive(msg): Promise<void> {
const data = await this.downstream.fetch(msg.id);
// ↑ everything in this actor's mailbox waits until fetch resolves.
this.handle(data);
}
}

The actor’s per-message guarantee says: only one onReceive invocation runs at a time. An await keeps the invocation alive until the promise settles — meaning the next message can’t be processed until then.

If downstream.fetch is fast and reliable, this is fine. If it’s slow or flaky, the actor’s mailbox piles up.

import { Actor, pipeTo, Success, Failure } from 'actor-ts';
type Msg =
| { kind: 'fetch'; id: string }
| Success<Item>
| Failure;
class Fast extends Actor<Msg> {
override onReceive(msg: Msg): void {
if (msg.kind === 'fetch') {
pipeTo(this.downstream.fetch(msg.id), this.context.self);
return; // doesn't block — onReceive returns immediately
}
if (msg instanceof Success) {
this.handle(msg.value);
} else if (msg instanceof Failure) {
this.log.warn(`fetch failed: ${msg.error.message}`);
}
}
}

pipeTo(promise, recipient) schedules a callback on the promise that tells the result to the recipient:

  • Fulfillmentrecipient.tell(new Success(value))
  • Rejectionrecipient.tell(new Failure(error))

The actor’s mailbox isn’t blocked. The result lands as a normal message, processed in order alongside everything else.

The full signature:

function pipeTo<T>(
promise: Promise<T>,
recipient: ActorRef,
options?: PipeToOptions,
): Promise<T>;
interface PipeToOptions {
sender?: ActorRef | null;
wrap?: boolean; // default true — wrap in Success/Failure
}
pipeTo(promise, target, { wrap: false });

With wrap: false, the raw fulfillment value is told to the target (no Success envelope), and rejections are dropped silently. Useful when:

  • The target expects the value’s raw shape (e.g. an HTTP-handler actor expecting the JSON body directly).
  • You handle errors elsewhere (a separate error pipe, or you’ve already attached a .catch).
pipeTo(promise, target, { sender: this.context.self });

By default, pipeTo sends with no sender ref. Pass sender to attribute the message to a specific actor — useful for routing the result back to whoever asked.

Success and Failure — the envelope types

Section titled “Success and Failure — the envelope types”
import { Success, Failure } from 'actor-ts';
new Success(42); // wraps a value
new Failure(new Error('oops')); // wraps an error

Simple tagged classes — values are immutable after construction. Pattern-match with instanceof:

override onReceive(msg: Success<Item> | Failure | Cmd): void {
if (msg instanceof Success) {
this.handle(msg.value);
} else if (msg instanceof Failure) {
this.fail(msg.error);
} else {
// it's a Cmd
}
}

Or in ts-pattern:

import { P, match } from 'ts-pattern';
match(msg)
.with(P.instanceOf(Success), (s) => this.handle(s.value))
.with(P.instanceOf(Failure), (f) => this.fail(f.error))
.otherwise(() => { /* ... */ });

Use them when the receiving actor’s message type already includes arbitrary results — the wrappers prevent accidental treatment of raw promise results as commands.

import { after } from 'actor-ts';
const result = await after(5_000, () => fetchSomething());

Wait 5 seconds, then call the factory and resolve with its result. The wait isn’t a blocking sleep — it’s setTimeout + a Promise — so the rest of the program runs normally.

The factory is called once after the delay; the returned Promise resolves/rejects with whatever the factory produces.

const p = after(5_000, () => fetchSomething());
// Later, maybe:
p.cancel();
// → p rejects with Error('after: cancelled')

The returned CancellablePromise<T> has a .cancel() method that clears the timer and rejects the promise. Useful for building timeout-style abort logic:

const work = expensiveOperation();
const watchdog = after(5_000, async () => { throw new Error('took too long'); });
try {
const winner = await Promise.race([work, watchdog]);
watchdog.cancel(); // we won, clean up the timer
return winner;
} catch (err) {
// either the work threw or the watchdog fired
throw err;
}
pipeTo(
after(1_000, () => this.downstream.fetch(id)),
this.context.self,
);

Schedule a downstream fetch in 1 second; pipe the result into the actor’s own mailbox. Useful for “kick off later but stay non-blocking” patterns.

context.timers.startSingleTimer and after look similar — both fire something after a delay. The differences:

Aspectcontext.timersafter
Delivers viatell to this.selfResolves a Promise
Cancellationtimers.cancel(key)cancellable.cancel()
Auto-cancel on actor stopYesNo
Best forActor-internal schedulingBridging actor + promise

If you’re inside an actor and want to schedule a delayed message, prefer context.timers. If you’re outside an actor or you want the delay to chain into a promise pipeline, use after.

  • Ask pattern — for the blocking await flavor when that’s actually what you want.
  • Timers and schedulingcontext.timers.startSingleTimer for actor-bound delays.
  • Retry — for retry-then-pipeTo combinations.

The pipeTo, after, and Success / Failure API references cover the full surface.