Behaviors
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.
The four constructors
Section titled “The four constructors”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 kind | When |
|---|---|
'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.
setup — capture the context once
Section titled “setup — capture the context once”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.
The decorators
Section titled “The decorators”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.
withTimers
Section titled “withTimers”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.
withStash
Section titled “withStash”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.
supervise(behavior).onFailure(strategy)
Section titled “supervise(behavior).onFailure(strategy)”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.
Scope: the wrapper outlives the subtree
Section titled “Scope: the wrapper outlives the subtree”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.
Nesting: the scopes layer
Section titled “Nesting: the scopes layer”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); // outerThe 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).
intercept
Section titled “intercept”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.
monitor
Section titled “monitor”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.
logMessages
Section titled “logMessages”const traced = Behaviors.logMessages(orders);
const audited = Behaviors.logMessages(orders, { level: 'info', formatter: (message) => `order ${message.orderId}`,});| Option | Default | Meaning |
|---|---|---|
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. |
formatter | built-in | Renders 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.
The five sentinels
Section titled “The five sentinels”Values you return from a handler to express a transition decision:
| Sentinel | Meaning |
|---|---|
Behaviors.same | Keep the current behavior. The handler closure runs again on the next message. |
Behaviors.stopped | Stop the actor. Equivalent to context.stopSelf() in the untyped form. |
Behaviors.unhandled | This message isn’t handled here; route to dead letters. |
Behaviors.empty | The behavior accepts messages but does nothing. Useful as a placeholder. |
Behaviors.ignore | Drop 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.
A multi-behavior example
Section titled “A multi-behavior example”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:
initializingstashes everything until aconfigurearrives, then transitions toready(url)after replaying the stash.readyhandles requests using the capturedurl.
The transitions are explicit returns; the state lives in closure
parameters; there’s no this to worry about.
Composition order
Section titled “Composition order”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.
Where to next
Section titled “Where to next”- 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.
