Typed overview
actor-ts ships two layers for the same actor model:
| API | Style | Source 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.
A direct comparison
Section titled “A direct comparison”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
countin a field, mutates it, returnsvoid. Classic OO. - The typed form passes
nas a parameter to the next behavior. No mutable state — the next behavior captures the new count in its closure.
What typed gets you
Section titled “What typed gets you”Four things the typed API does that untyped doesn’t:
- 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.xmutation, especially when refactoring. - Pure-functional message handling. Handlers are
(ctx, msg) => Behavior<T>— no side effects onthis, no imperative state machine. Easier to test in isolation; a behavior is just a value. Behaviors.same/Behaviors.stoppedsentinels. 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 imperativecontext.stopSelf()calls.- Cast-free child spawning.
ctx.spawn(behavior)returnsActorRef<U>typed to the child’s message type, derived from the behavior. Noas ActorRef<...>casts at spawn time.
What untyped gets you
Section titled “What untyped gets you”Three things the untyped API does more cleanly:
- 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. Mutatingthisis fine. - Lifecycle hooks (
preStart,postStop,preRestart). The typed API exposes these asSignalevents on areceiveWithSignalhandler, which is a bit more verbose than overriding a method. - 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.
When to pick which
Section titled “When to pick which”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.
What the typed surface looks like
Section titled “What the typed surface looks like”A handful of building blocks:
| Combinator | What 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.same | Sentinel: keep the current behavior. |
Behaviors.stopped | Sentinel: stop the actor. |
Behaviors.unhandled | Sentinel: this message is unhandled (goes to dead letters). |
Behaviors.empty | Sentinel: no-op handler. |
Behaviors.ignore | Sentinel: 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.
Where to next
Section titled “Where to next”- Behaviors — the full DSL.
- Typed actor — what runs a Behavior at runtime.
- Spawn typed —
spawnTyped,typedProps,spawnTypedChild. - Fundamentals overview — the untyped concept map; many ideas carry over.