Ir al contenido
Español

Actor

Esta página aún no está disponible en tu idioma.

The Actor class is the foundational primitive in actor-ts. Every component you build by extending Actor gets a private mailbox, a single-threaded onReceive loop, lifecycle hooks, and a handful of context references (self, sender, system, log, context) for talking to the rest of the runtime.

Everything else in the framework — supervision, sharding, persistence, HTTP, brokers — builds on the primitives this page covers.

Three properties define an actor in actor-ts:

  1. A private mailbox. Messages sent to the actor enter this mailbox. No one — not even the actor itself — can reach into another actor’s mailbox directly.
  2. A single-threaded message loop. The framework processes messages from the mailbox one at a time. Two messages never run concurrently for the same actor; inside onReceive you read and mutate the actor’s fields without locks or atomics.
  3. A handle, not a reference to the instance. When you “send a message to an actor,” what you actually have is an ActorRef<T> — a thin location-transparent handle. The framework holds the real Actor instance. You can pass refs across the cluster, store them, hand them to other actors; you can never see the underlying class.
import { match } from 'ts-pattern';
import { Actor, ActorSystem } from 'actor-ts';
type IncrementCommand = { kind: 'increment' };
type ResetCommand = { kind: 'reset' };
type Command = IncrementCommand | ResetCommand;
class Counter extends Actor<Command> {
private count = 0;
override onReceive(command: Command): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'reset' }, () => this.onReset())
.exhaustive();
this.log.info(`count is now ${this.count}`);
}
private onIncrement(): void { this.count++; }
private onReset(): void { this.count = 0; }
}

Three things to notice:

  • The type parameter <Command> constrains what messages this actor accepts. ref.tell({ kind: 'bogus' }) is a TypeScript error at the call site. See Messages for the discriminated-union convention this codebase uses everywhere.
  • override onReceive(command: Command): void is the only required override. The base class declares it abstract, so the compiler refuses to compile if you forget it.
  • this.log, this.count — you have full access to instance fields. Single-threaded processing means concurrent reads/writes aren’t a concern; you reason about state like you would in a single-threaded program.

You don’t new Counter() directly. The framework needs to control when the instance is created (on its own mailbox-processing thread, not on the caller’s) and it needs to inject context (the self ref, the supervisor strategy, the parent reference). So instead of an instance, you hand over the class itself:

const system = ActorSystem.create('demo');
// Anonymous actor — system picks a name.
const counter = system.spawnAnonymous(Counter);
// Named actor — useful for log lines + path-based lookup.
const counter2 = system.spawn(Counter, 'counter');

A constructor that takes arguments has no zero-argument form to call, so that case hands over a factory instead — system.spawn(() => new Counter(database), 'counter'). The optional third argument, ActorOptions, carries the supervisor strategy, dispatcher and mailbox choice. See Spawning actors for the full surface.

The returned ActorRef has one verb that matters: tell(message).

counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'reset' });

tell is fire-and-forget. It enqueues the message into the mailbox and returns immediately — the actor processes it on its own schedule. For request/response, see Ask pattern.

Inside onReceive (and the lifecycle hooks), four references give you access to the runtime:

class Echo extends Actor<{ text: string }> {
override onReceive(message: { text: string }): void {
// 1. `this.self` — a ref to me. Pass this around when other
// actors should be able to talk to me.
this.log.info(`I am ${this.self.path}`);
// 2. `this.sender` — Option<ActorRef> for who sent this message.
// `None` when no sender was attached (e.g. `tell` from outside
// the actor system). Use `.forEach(...)` to reply only when
// there is one.
this.sender.forEach((replyTo) => replyTo.tell({ echo: message.text }));
// 3. `this.system` — the ActorSystem. Spawn top-level actors,
// schedule timers, register extensions.
void this.system;
// 4. `this.log` — a Logger pre-wired with the actor's path as
// structured-log context.
this.log.debug('echo done');
// (5. `this.context` — the full ActorContext for deeper APIs:
// `context.spawnAnonymous(...)` for children, `context.watch(...)` for
// death-watch, `context.become(...)` for behavior switching.)
void this.context;
}
}

sender is Option<ActorRef> because not every message has a sender: a tell from outside the actor world (e.g. from an HTTP handler) attaches None. Use .forEach to reply only if one is present, or .getOrElse(system.deadLetters) if you want a fallback.

An actor in a system that has joined a cluster reaches it the same way, without anything being passed into the constructor:

class RoomActor extends Actor<RoomMessage> {
override preStart(): void {
this.log.info(`hosted on ${this.cluster.selfAddress}`);
this.log.info(`${this.cluster.upMembers().length} member(s) up`);
}
}

this.cluster throws when the system never joined one, on the assumption that code reaching for it already knows it is clustered — the same trade-off this.entityId makes. When the actor has to work either way, ask this.context.cluster, which is an Option<Cluster> and answers None instead:

this.context.cluster.forEach((cluster) => cluster.subscribe(onEvent));

Both read through to system.cluster on every access, so an actor that was already running when Cluster.join(...) completed picks it up. See Cluster overview.

The base class defines four lifecycle hooks; all default to no-op. Override the ones you need.

class FileWriter extends Actor<{ line: string }> {
private handle!: FileHandle;
override async preStart(): Promise<void> {
this.handle = await fs.open(this.config.path, 'a');
this.log.info('opened ' + this.config.path);
}
override onReceive(message: { line: string }): void {
this.handle.write(message.line + '\n').catch((e) => this.log.warn(e));
}
override async postStop(): Promise<void> {
await this.handle.close();
this.log.info('closed ' + this.config.path);
}
}

The four hooks:

HookWhen it runsDefault
preStart()After construction, before the first message. Open files, connect to brokers, register subscribers here.no-op
postStop()After the actor has stopped (children already stopped). Close resources, deregister. Runs once.no-op
preRestart(reason, message?)Before a restart, on the about-to-be-thrown-away instance. Default calls postStop().calls postStop()
postRestart(reason)On the fresh instance after a restart. Default calls preStart().calls preStart()

All four can return Promise<void> — the framework awaits before the next phase. A throw in preStart is escalated to the supervisor.

Not a lifecycle hook, but the other method worth overriding. It is what an actor is called in log lines and in the DevTools tree; by default, its full path:

class UserEntity extends Actor<UserCommand> {
override displayName(): string { return `User(${this.entityId})`; }
override onReceive(command: UserCommand): void {
this.log.info('recovery complete');
// → [...] INFO actor-ts://shop/.../entity-da76e1eb-... - User(test-user-590) - recovery complete
}
}

The name is added to the line, not substituted for the path, and it is resolved on every record — so it may be derived from state, and it follows a restart. Keep it cheap; a throw falls back to the path. ActorOptions.withDisplayName(...) at the spawn site outranks it, exactly as withSupervisorStrategy(...) outranks supervisorStrategy(). Full story in Logging.

Actors can have children. Use context.spawn from inside the parent’s onReceive:

class Parent extends Actor<{ kind: 'add-child'; name: string }> {
override onReceive(message: { kind: 'add-child'; name: string }): void {
const child = this.context.spawn(Worker, message.name);
this.log.info(`spawned child ${child.path}`);
}
}

The child’s full path becomes /<parent>/<child-name>. Children are tied to the parent’s lifecycle — when the parent stops, all children stop first (postStop runs bottom-up). Supervision decisions for a child go to the parent’s supervisor strategy.

See Actor paths for the path hierarchy and Supervision for how parent-child failure propagation works.

onReceive can be async (returns Promise<void>):

class DbWriter extends Actor<{ row: Row }> {
override async onReceive(message: { row: Row }): Promise<void> {
await this.db.insert(message.row);
this.log.debug('wrote row ' + message.row.id);
}
}

The framework awaits the returned promise before pulling the next message from the mailbox. So db.insert running serially per-actor is guaranteed — no need for a transaction-per-message guard.

The trade-off is throughput: a slow await blocks this actor’s message processing. Other actors aren’t affected (they have their own mailboxes), but if you’ve created one actor per HTTP request and each one waits 500 ms on a DB call, your throughput is capped at one request per 500 ms per actor.

The usual fix is to spawn a child per long-running task, let it run the slow op, and have it tell the parent the result:

class DbWriterFanout extends Actor<{ row: Row }> {
override onReceive(message: { row: Row }): void {
// Spawn-and-forget: child does the slow DB write, parent stays
// available for the next message in the mailbox.
this.context.spawnAnonymous(() =>
new SingleShotWriter(this.db, message.row));
}
}
  • Messages — the discriminated- union convention every actor uses for its incoming type.
  • Actor paths — how parent/child hierarchies form addresses.
  • Supervision — what happens when onReceive throws.
  • Become and stash — switching behavior at runtime and buffering messages.
  • Ask pattern — request/response when fire-and-forget isn’t enough.

The Actor class API reference has the full signature for every method discussed here.