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.
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.
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.
See Supervision for the directive semantics; they apply identically in the typed form.
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.
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.
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.
