Skip to content

Typed overview

actor-ts ships two layers for the same actor model:

APIStyleSource of truth
Untyped (the fundamentals)Subclass Actor<TMsg>, override onReceive, mutate this.state.This is the lower-level, more flexible API.
Typed (this section)Compose Behavior<T> values via Behaviors.receive(...), return next behavior from handlers.A functional facade on top of the untyped engine.

The framework runs both on the same dispatcher, the same mailbox, the same supervision tree. The choice is expressive style, not capability.

Same counter, two ways:

Untyped:

import { Actor, Props, ActorSystem } from 'actor-ts';
type Cmd = { kind: 'inc' } | { kind: 'dec' };
class Counter extends Actor<Cmd> {
private count = 0;
override onReceive(cmd: Cmd): void {
if (cmd.kind === 'inc') this.count++;
else this.count--;
}
}
const system = ActorSystem.create('demo');
const counter = system.actorOf(Props.create(() => new Counter()));

Typed:

import { Behaviors, spawnTyped, ActorSystem } from 'actor-ts';
type Cmd = { kind: 'inc' } | { kind: 'dec' };
const counter = (n: number) => Behaviors.receive<Cmd>((_ctx, cmd) =>
cmd.kind === 'inc' ? counter(n + 1) : counter(n - 1)
);
const system = ActorSystem.create('demo');
const ref = spawnTyped(system, counter(0));

Same runtime semantics; different ergonomics:

  • The untyped form keeps count in a field, mutates it, returns void. Classic OO.
  • The typed form passes n as a parameter to the next behavior. No mutable state — the next behavior captures the new count in its closure.

Four things the typed API does that untyped doesn’t:

  1. State as parameters, not fields. Each behavior captures its state in a closure; the “next state” is what you return from the handler. Less footgun-prone than this.x mutation, especially when refactoring.
  2. Pure-functional message handling. Handlers are (ctx, msg) => Behavior<T> — no side effects on this, no imperative state machine. Easier to test in isolation; a behavior is just a value.
  3. Behaviors.same / Behaviors.stopped sentinels. The handler return type is the actor’s state-transition decision, so “keep the same behavior” and “stop the actor” are values you return rather than imperative context.stopSelf() calls.
  4. Cast-free child spawning. ctx.spawn(behavior) returns ActorRef<U> typed to the child’s message type, derived from the behavior. No as ActorRef<...> casts at spawn time.

Three things the untyped API does more cleanly:

  1. Mutable state across messages. When the actor has a complex aggregate (a Map, a buffer, a state machine with five fields), threading it through every behavior return gets awkward. Mutating this is fine.
  2. Lifecycle hooks (preStart, postStop, preRestart). The typed API exposes these as Signal events on a receiveWithSignal handler, which is a bit more verbose than overriding a method.
  3. Direct interop with the rest of the framework. Most examples in the docs use the untyped form. Most cluster extensions (sharding, singleton, pubsub) are typed in terms of ActorRef<T> either way — but their internals are untyped classes, and that occasionally leaks at the seams.

Pick typed when:

  • The actor is a state machine and you want phases to be different behaviors (different valid-message sets per phase). The typed form makes the transitions explicit returns.
  • You’re coming from Akka-Typed, Cats Effect, or fp-ts and the functional style feels native.
  • You want compiler-checked state-transition logic — typed behaviors compose at type level.

Pick untyped when:

  • The actor has substantial mutable state (a cache, a buffer, a set of subscribers) and threading it through behaviors is more ceremony than value.
  • You’re learning the actor model and don’t want a second layer of abstraction in the way.
  • You want one-to-one parity with Akka-untyped or other actor libraries you’ve used.

You can mix them. An untyped parent can spawn typed children via spawnTypedChild(ctx, behavior), and a typed parent can spawn untyped children via ctx.spawn(behavior) where the behavior wraps an Actor subclass. Refs are interoperable — both forms expose ActorRef<T> with the same tell(msg) signature.

A handful of building blocks:

CombinatorWhat it does
Behaviors.setup(ctx => behavior)Run once with the context; returns the initial behavior. Akin to a “constructor.”
Behaviors.receive((ctx, msg) => behavior)Standard message handler. Returns the next behavior.
Behaviors.receiveMessage(msg => behavior)Shortcut when you don’t need the context.
Behaviors.receiveWithSignal(handler, signalHandler)Add a lifecycle-signal handler (postStop, preRestart, terminated).
Behaviors.withTimers(timers => behavior)Capture the per-actor TimerScheduler in a closure.
Behaviors.withStash(capacity, stash => behavior)Capture a StashBuffer<T> with the given capacity.
Behaviors.supervise(behavior).onFailure(strategy)Wrap a behavior with a supervisor strategy.
Behaviors.sameSentinel: keep the current behavior.
Behaviors.stoppedSentinel: stop the actor.
Behaviors.unhandledSentinel: this message is unhandled (goes to dead letters).
Behaviors.emptySentinel: no-op handler.
Behaviors.ignoreSentinel: silently drop every message.

Each page in this section drills into one slice — see Behaviors for the combinators, Typed actor for the engine that hosts a behavior, Spawn typed for the three spawn helpers.