Pular para o conteúdo
Português (BR)

TypedActor

Este conteúdo não está disponível em sua língua ainda.

TypedActor<T> is the runtime host that takes a Behavior<T> value and runs it. Internally, it’s an Actor<T> subclass — same mailbox, same dispatcher, same supervisor tree — but its onReceive delegates into whichever Behavior is currently active.

You don’t construct TypedActor directly. The spawn methods (system.spawnTyped / context.spawnTyped and typedActor) wrap it for you. But knowing how it works clarifies the typed-DSL semantics — especially what “the runtime interprets the Behavior” actually means.

import { ActorSystem, Behaviors } from 'actor-ts';
const myBehavior = Behaviors.receive<Message>((context, message) => {
// ... handle message
return Behaviors.same;
});
const system = ActorSystem.create('demo');
const ref = system.spawnTypedAnonymous(myBehavior);
// Under the hood: system.spawnAnonymous(() => new TypedActor(myBehavior))

The framework:

  1. Constructs a TypedActor<T> with myBehavior as the initial value.
  2. In preStart, resolves the initial behavior — walks through any setup / withTimers / withStash / supervise wrappers until it lands on a leaf (a receive or a sentinel).
  3. On every message, calls the resolved handler. The return value becomes the new “current behavior.”
  4. If the return is same, nothing changes. If it’s a fresh behavior, the framework resolves that and adopts it as the new current.

Composed behaviors unwrap one layer at a time. Given:

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

The resolver walks:

supervise(...) ← capture supervise strategy, recurse into child
withTimers(...) ← capture timer scheduler, recurse into factory's return
setup(...) ← run factory(context), recurse into return
receive(...) ← leaf — store as `current`

Each wrapper has its side effect once (install the supervisor, capture the timers, run the setup factory) and disappears. The final shape is a plain receive value. The framework remembers the supervise strategy and timer scheduler across the actor’s lifetime; the setup factory only runs the first time and on restart.

“Remembers” is a stack, not a slot: a walk that passes two supervise wrappers records two scopes, innermost last. A failure is offered to them innermost-first and Directive.Escalate moves one scope out, so an outer wrapper is not silently displaced by an inner one — see Nesting. A restart shortens the stack, and only below the scope that decided it, because re-resolving that scope’s child rebuilds the wrappers underneath.

The signal handler is the one thing the walk does not remember across a transition: it belongs to the receive node that declared it, so adopting a receive without an onSignal unregisters it. Only the sentinels leave it alone, which is what lets a behavior answer Behaviors.stopped and still see its post-stop.

A wrapper cycle (a setup that returns itself, or a supervise that recurses) is bounded at 64 hops — after that the resolver throws, surfacing the misconfiguration loud rather than spinning forever.

Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'start' }, () => runningBehavior)
.with({ kind: 'stop' }, () => Behaviors.stopped)
.otherwise(() => Behaviors.same));

Four outcomes:

  • Behaviors.same → the same closure handles the next message. The runtime keeps current unchanged.
  • A different Behavior<T> value → the runtime resolves it and swaps it in. Wrappers run their side effects again (a fresh setup runs, fresh timers get captured if withTimers is re-introduced — important for “phase 1 had a timer, phase 2 doesn’t” patterns).
  • Behaviors.stopped → the runtime stops the actor.
  • Behaviors.unhandled → the message routes to dead letters; the current behavior stays.
Behaviors.receiveWithSignal<Message>(
(context, message) => Behaviors.same,
(context, signal) => match(signal)
.with({ kind: 'post-stop' }, () => { context.log.info('cleaning up'); return Behaviors.same; })
.with({ kind: 'pre-restart' }, (s) => { context.log.warn(`restarting: ${s.reason}`); return Behaviors.same; })
.with({ kind: 'terminated' }, (s) => { context.log.info(`${s.ref.path} stopped`); return Behaviors.same; })
.exhaustive(),
);

The signal handler is invoked from the framework’s postStop / preRestart / Terminated-message-delivery hooks. Three signals:

KindTriggered by
post-stopThe actor is stopping (any reason: PoisonPill, Behaviors.stopped, supervisor Stop).
pre-restartA failure is about to be restarted. signal.reason is the error.
terminatedA watched actor (context.watch(ref)) stopped. signal.ref is its ref.

The return value works the same as the receive handler — same to keep the current behavior, a new behavior to swap.

The handler is scoped to the behavior that declared it. Transition to a Behaviors.receive with no onSignal and the registration is gone: post-stop and pre-restart fire nowhere, and a watched actor’s death arrives at the receive handler as an ordinary Terminated message again. Re-declare onSignal in each state that needs it — see receiveWithSignal.

TypedActor is the bridge — and at the seam between typed and untyped, a few details surface:

  • Failures inside a typed handler reach the typed-supervisor first. If the handler is wrapped in Behaviors.supervise(...).onFailure(strategy), that strategy handles the error. If not, it propagates to the parent’s supervisor — same as untyped. So does a failure the wrapper has run out of restarts for: once maxRetries is spent within withinTimeRangeMs, the typed level stops absorbing and the error carries on. With supervise wrappers nested, “carries on” means the next wrapper out gets its turn first; the parent’s supervisor is what lies past the outermost one.
  • context.spawn(behavior) returns a fully-typed ActorRef<U>. The runtime wraps the child in another TypedActor<U>. This is the cast-free child spawning the typed form provides.
  • Watching is one-way. A typed actor can context.watch(ref) an untyped ref or vice versa. The terminated signal arrives at the typed side via the terminated signal; at the untyped side via a Terminated message.

The behavior you pass to system.spawnTypedAnonymous(behavior) becomes the initial current. Two edge cases:

  • Behaviors.same as initial is meaningless — there’s nothing to keep. The runtime treats it as Behaviors.empty (silent no-op), so the actor exists but drops all messages. This usually surfaces a logic bug somewhere.
  • Behaviors.stopped as initial stops the actor in preStart. The actor’s ref is returned but it’s already terminating. Useful when “should this actor exist?” is determined by some external check at spawn time.
  • Behaviors — the DSL that produces the values TypedActor interprets.
  • Spawn typed — the three helpers wrapping TypedActor for normal use.
  • Actor (untyped) — the parent class TypedActor extends.
  • Supervision — what the Behaviors.supervise(...).onFailure(...) strategy routes to.