Ir al contenido
Español

Behaviors

Esta página aún no está disponible en tu idioma.

The Behaviors namespace is a collection of combinators that build Behavior<T> values. A behavior describes what the actor does when the next message arrives — and what behavior it adopts after that. An actor’s lifetime is just a sequence of behaviors: each handler returns the next one.

import { ActorSystem, Behaviors } from 'actor-ts';
type Message = { kind: 'tick' };
const ticker = Behaviors.receive<Message>((context, message) => {
context.log.info(`tick at ${Date.now()}`);
return Behaviors.same; // keep being a ticker
});
const system = ActorSystem.create('demo');
const ref = system.spawnTypedAnonymous(ticker);

Behaviors.receive builds the most common behavior: a handler that runs on every message and returns the next behavior. The same sentinel says “stay as I am” — the same closure handles the next message.

receive — handler with context + message

Section titled “receive — handler with context + message”
const counter = (n: number): Behavior<Message> => Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.with({ kind: 'decrement' }, () => counter(n - 1))
.with({ kind: 'get' }, (m) => { m.replyTo.tell(n); return Behaviors.same; })
.exhaustive());

The handler receives both a TypedActorContext<T> (for spawning children, logging, watching) and the message. Returns the next behavior — Behaviors.same keeps the closure; a fresh counter(n + 1) adopts a new closure with updated state.

receiveMessage — handler without context

Section titled “receiveMessage — handler without context”
const counter = (n: number): Behavior<Message> => Behaviors.receiveMessage<Message>((message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.otherwise(() => Behaviors.same));

Shortcut for the common case where you don’t need the context. Equivalent to Behaviors.receive((_context, message) => ...).

receiveWithSignal — handler + lifecycle signals

Section titled “receiveWithSignal — handler + lifecycle signals”
const watcher = Behaviors.receiveWithSignal<Message>(
(context, message) => {
// handle user message...
return Behaviors.same;
},
(context, signal) => match(signal)
.with({ kind: 'terminated' }, (s) => {
context.log.info(`watched actor ${s.ref.path} stopped`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same),
);

The signal handler fires for lifecycle events:

Signal kindWhen
'post-stop'The actor is stopping. Use for cleanup.
'pre-restart'The supervisor is about to restart the actor. signal.reason is the error.
'terminated'A watched actor stopped. signal.ref is its ref.

This is the typed-DSL equivalent of overriding postStop, preRestart, and handling Terminated messages in the untyped form.

The handler belongs to this behavior, not to the actor. Transitioning to a Behaviors.receive that declares no onSignal unregisters it — so a state machine that wants post-stop cleanup, or wants a watched actor’s death as a signal rather than as a message, declares onSignal in every state that needs it:

const draining = Behaviors.receiveWithSignal<Message>(
(context, message) => Behaviors.same,
(context, signal) => { cleanUp(); return Behaviors.same; },
);
// Keeps the cleanup — the next state declares its own handler.
const working = Behaviors.receiveWithSignal<Message>(
(context, message) => draining,
(context, signal) => { cleanUp(); return Behaviors.same; },
);
// Loses it — a plain `receive` declares no signals, so from here on
// `post-stop` fires nowhere and a `Terminated` arrives at the receive
// handler as an ordinary message.
const forgetful = Behaviors.receiveWithSignal<Message>(
(context, message) => Behaviors.receive((innerContext, innerMessage) => Behaviors.same),
(context, signal) => { cleanUp(); return Behaviors.same; },
);

The sentinels are the one exception: Behaviors.stopped leaves the handler installed, because a post-stop handler that stopped working the moment the actor stopped itself would be useless.

const myActor = Behaviors.setup<Message>((context) => {
context.log.info(`I'm starting at ${context.path}`);
const helper = context.spawn(helperBehavior, 'helper');
return Behaviors.receive((_context, message) => {
helper.tell(message); // helper captured in closure
return Behaviors.same;
});
});

setup runs once when the actor starts. Use it for one-time initialization that the receive-handler should close over: spawning children, capturing context.self for the children to know, opening external connections.

It is also where a behavior names itself. Every Behavior runs inside the same TypedActor class, so there is no subclass of yours to override Actor.displayName() on — context.setDisplayName is the way in, and its effect is the same: log lines and the DevTools tree gain a readable label beside the path.

const cart = (customerId: string): Behavior<Message> => Behaviors.setup((context) => {
context.setDisplayName(`Cart(${customerId})`);
return Behaviors.receive((_context, message) => { /* ... */ return Behaviors.same; });
});

Without it a whole tree of typed actors reports TypedActor as its class, which tells you nothing about which is which. Call it any time, not only from setup — a name that only settles after the first message can be set then. For a name the spawn site already knows, ActorOptions.withDisplayName(...) does the same without entering the behavior.

Six combinators wrap another behavior with extra capabilities. The first three hand the inner behavior something it could not reach on its own — a timer scheduler, a stash buffer, a supervisor. The last three sit in front of it, on the path every message takes.

import { Behaviors, type TimerScheduler } from 'actor-ts';
const heartbeat = Behaviors.withTimers<Message>((timers) => {
timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, 5_000);
return Behaviors.receiveMessage((message) =>
match(message)
.with({ kind: 'tick' }, () => { console.log('heartbeat'); return Behaviors.same; })
.otherwise(() => Behaviors.same));
});

The TimerScheduler API is the same one context.timers gives in the untyped form. withTimers captures it in a closure so the receive handler has access without going through context.timers on every message.

const init = Behaviors.withStash<Message>(100, (stash) => {
return Behaviors.receive((context, message) =>
match(message)
.with({ kind: 'ready' }, () => {
stash.unstashAll(); // replay all buffered messages
return ready;
})
.otherwise((m) => {
stash.stash(m); // park everything else for later
return Behaviors.same;
}));
});
const ready = Behaviors.receive<Message>((context, message) => {
// handle messages normally
return Behaviors.same;
});

Capacity-bounded stash, with stash / unstashAll / isEmpty / isFull / size. Same semantics as the untyped context.stash, exposed as a value rather than via context — including the two that matter most: unstashAll() puts the buffered messages back at the front of the mailbox, so they are handled before anything that arrived while the stash was filling, and whatever is still parked when the actor stops or restarts goes to dead letters instead of vanishing.

Two differences from the context form, both deliberate. The capacity is the one you hand withStash, per behavior, rather than one default for the whole actor. And stash(message) takes the message explicitly, so it need not be the one currently being handled — which is also why a dead letter minted from this buffer carries no original sender.

import { Behaviors, OneForOneStrategy, Directive } from 'actor-ts';
const supervised = Behaviors
.supervise(myReceiveBehavior)
.onFailure(new OneForOneStrategy(
(err) => Directive.Restart,
{ maxRetries: 5, withinTimeRangeMs: 60_000 },
));

Wrap a behavior with a supervisor strategy. Errors thrown from the inner handler are routed through the strategy — Restart re-initializes the behavior (resets to its initial form), Stop terminates, Resume skips the failing message.

maxRetries and withinTimeRangeMs bound how often Restart may fire. The example above grants five restarts per sliding minute; the sixth failure inside that minute escalates instead — the error is rethrown to the actor’s cell, where the parent’s strategy decides. maxRetries: -1, what a hand-built OneForOneStrategy defaults to, is unlimited; maxRetries: 0 never restarts at all.

The tally belongs to the supervise wrapper rather than to the strategy, so one strategy value handed to several behaviors gives each of them its own allowance. It accumulates across the restarts it counts; only installing a new supervise wrapper starts a fresh one.

Escalating past the bound layers the two supervisors instead of duplicating them. A restart the parent grants builds a brand-new actor, so this per-behavior allowance starts over and the outer bound becomes the parent’s own strategy — ten restarts per minute by default. A behavior that always throws therefore stops looping in place after maxRetries, and stops for good once the parent’s budget is spent too.

supervise installs a scope, and the scope lasts as long as the actor does. A behavior the wrapped one transitions to is still supervised, even though the wrapper is nowhere in the value it returned:

const supervised = Behaviors
.supervise(Behaviors.receiveMessage<Message>((message) => nextPhase))
.onFailure(strategy);
// `nextPhase` throwing is routed through `strategy` too.

This is the same rule intercept follows, and for the same reason: the wrapper contributes its side effect once and the framework remembers the strategy. Behaviors.stopped is the way out of a supervision scope; a transition is not.

Two wrappers make two scopes, and the inner one gets the first say:

const layered = Behaviors
.supervise(
Behaviors.supervise(leaf).onFailure(retryTwice), // inner
)
.onFailure(giveUpAndStop); // outer

The innermost strategy decides. Directive.Escalate — and a restart budget the scope has spent — hand the same error to the next scope out, which is exactly what Escalate means everywhere else in the framework. Only falling off the outermost wrapper leaves the actor, where the cell’s parent strategy takes over. So layered above retries twice inside the actor and then stops it, rather than the inner wrapper’s exhaustion going straight to the parent.

A scope a running behavior installs — a handler returning a fresh Behaviors.supervise(...) — nests inside the ones already active rather than displacing them.

A restart decided by an outer scope re-resolves that scope’s own child, which rebuilds the wrappers below it — so the inner scopes come back with a full restart allowance. That is the same split a restart already applies to interceptors: what lives inside the wrapper is part of what restarts.

See Supervision for the directive semantics; they apply identically in the typed form, with one difference in how the bound is counted (noted there).

const guarded = Behaviors.intercept<Message>(inner, (context, message, next) => {
if (message.kind === 'ping') return Behaviors.same; // drop — inner never sees it
return next(context, message); // or delegate
});

The interceptor runs first on every message and decides what happens next: call next(context, message) to delegate, pass a different message to transform it on the way in, or return a behavior without calling next at all to drop it. Whatever it returns becomes the inner behavior’s next behavior.

The wrapper survives the inner behavior’s transitions. That is the part worth remembering: an inner Behaviors.receive that returns a fresh behavior on every message — the normal shape of a state machine — is still intercepted on the next one, and on every one after that. The only way out is Behaviors.stopped, where there is nothing left to intercept.

Errors thrown by the interceptor are treated exactly like errors from the inner handler: they reach an enclosing supervise. Interception covers user messages only; lifecycle signals go straight to receiveWithSignal’s handler.

The type is T → T — an interceptor observes, transforms, or drops, it never changes the actor’s message type.

const audit = system.spawnTyped(auditBehavior, 'audit');
const monitored = Behaviors.monitor(audit, orders);

Forwards every message to audit before orders handles it. Useful for audit trails and, in tests, for a probe that asserts on traffic the actor received:

const probe = kit.createTestProbe();
const ref = kit.system.spawnTypedAnonymous(Behaviors.monitor(probe, orders));

Forward-then-deliver is the deliberate order: the monitor sees a message even if handling it crashes the actor, which is the case you most want the trace for. Delivery is fire-and-forget and its failures are swallowed — a broken tap must not take the actor down with it.

const traced = Behaviors.logMessages(orders);
const audited = Behaviors.logMessages(orders, {
level: 'info',
formatter: (message) => `order ${message.orderId}`,
});
OptionDefaultMeaning
level'debug''debug' or 'info'. Logging every message is a diagnostic; reporting it at warn or error would poison the signal an operator filters on, so those are not offered.
formatterbuilt-inRenders the whole line. Must not throw — if it does, the built-in line is emitted instead, because a diagnostic that kills the actor it observes is worse than one that reads a little worse.

The built-in line is received <kind>, naming the message by its discriminant. For a class instance it falls back to the class name, and then to typeof — a bare object literal reports Object as its constructor, which would say nothing.

The line is only built when the actor’s logger would actually emit it, so leaving this in place on a system logging at warn costs one comparison per message rather than a formatted string.

Values you return from a handler to express a transition decision:

SentinelMeaning
Behaviors.sameKeep the current behavior. The handler closure runs again on the next message.
Behaviors.stoppedStop the actor. Equivalent to context.stopSelf() in the untyped form.
Behaviors.unhandledThis message isn’t handled here; route to dead letters.
Behaviors.emptyThe behavior accepts messages but does nothing. Useful as a placeholder.
Behaviors.ignoreDrop every message silently (no dead-letter routing).

The first three are the most useful day-to-day. empty and ignore exist for special cases — a “this actor is intentionally silent for now” stub or a sink that should swallow traffic.

import { match } from 'ts-pattern';
import { ActorSystem, Behaviors, type Behavior } from 'actor-ts';
type ConfigureMessage = { kind: 'configure'; url: string };
type RequestMessage = { kind: 'request'; payload: string };
type Message = ConfigureMessage | RequestMessage;
const initializing = Behaviors.withStash<Message>(100, (stash) =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'configure' }, (m) => {
stash.unstashAll();
return ready(m.url);
})
.otherwise((m) => {
stash.stash(m);
return Behaviors.same;
})),
);
const ready = (url: string): Behavior<Message> =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'request' }, (m) => {
context.log.info(`POST ${url}: ${m.payload}`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same));
const system = ActorSystem.create('demo');
system.spawnTypedAnonymous(initializing);

Two behaviors:

  • initializing stashes everything until a configure arrives, then transitions to ready(url) after replaying the stash.
  • ready handles requests using the captured url.

The transitions are explicit returns; the state lives in closure parameters; there’s no this to worry about.

Decorators compose outside-in. Behaviors.supervise(Behaviors.withTimers(...)) means “supervise the timers-using behavior”; Behaviors.withTimers(Behaviors.supervise(...)) means “give the supervised inner the timers.” In practice:

const supervised = Behaviors
.supervise(Behaviors.withTimers((timers) =>
Behaviors.receive((context, message) => Behaviors.same)
))
.onFailure(strategy);

supervise is outside the withTimers, so the strategy oversees the whole construction. This is almost always the right nesting.

Interceptors read the same way, and because they run on every message the order is directly observable:

const traced = Behaviors.logMessages(Behaviors.monitor(auditRef, inner));

logMessages is outermost, so it logs first, then the monitor forwards, then inner handles. Each wrapper decides whether the next one downwards gets the message at all — an interceptor that returns without calling next stops everything below it.

supervise around supervise reads the same way and is the one composition where the order changes who decides: the innermost wrapper is asked first and hands the error outwards. See Nesting above.

One asymmetry is worth knowing. The other decorators are resolved away once the actor starts: they contribute their side effect (capturing timers, installing a strategy) and collapse into the behavior they produced. An interceptor cannot, because it has to be there on the next message too — so it stays wrapped around whatever the inner behavior becomes.

Collapsing is not the same as expiring, though. A supervise scope outlives the value that installed it and covers whatever the actor transitions into next; only the wrapper node is gone. A signal handler is the opposite — it is a field of the receive behavior that declared it, so it goes away with that behavior.

That same distinction decides what a restart rebuilds. supervise restarts what it wraps, so an interceptor inside the wrapper is part of the fresh behavior and is rebuilt along with it; one outside is not, and keeps observing across the restart. Either nesting leaves it installed exactly once — a crash-looping actor does not accumulate copies of its own monitor, and monitor does not start delivering a message twice because the actor restarted earlier.

  • Typed actor — the runtime that interprets a Behavior.
  • Spawn typed — system.spawnTyped, context.spawnTyped, typedActor.
  • Supervision — what Behaviors.supervise(...).onFailure(...) uses internally.
  • Become and stash (untyped) — the OO equivalents of Behaviors.withStash + behavior-switching via return values.