跳转到内容
简体中文

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.

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.

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.

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.

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.

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.

  • Typed actor — the runtime that interprets a Behavior.
  • Spawn typedsystem.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.