Aller au contenu
Français

Messages

Ce contenu n’est pas encore disponible dans votre langue.

A message is any value sent to an actor. The type system enforces which messages a given actor accepts (via the Actor<TMessage> type parameter), and the runtime delivers messages one at a time to the actor’s onReceive.

This page covers the conventions actor-ts code follows for shaping messages — what they look like, how to dispatch on them, what NOT to put in them, and the patterns that come up most often.

The single most-important pattern in this codebase: messages are discriminated unions tagged by a kind field.

type CounterCommand =
| { readonly kind: 'increment' }
| { readonly kind: 'decrement' }
| { readonly kind: 'set'; readonly to: number }
| { readonly kind: 'get'; readonly replyTo: ActorRef<number> };

This shape buys three things:

  1. Compile-time exhaustiveness. match(command).with(...).exhaustive() (via ts-pattern) refuses to compile if you add a new kind and forget to handle it. The actor-ts codebase uses this pattern everywhere — see Pattern matching.
  2. Type narrowing inside handlers. Inside the .with({ kind: 'set' }, ...) arm, m.to is typed as number automatically — no casts, no as never.
  3. Wire-format stability. The wire carries no type identity of its own — once a message that crossed a process or cluster boundary has been parsed, kind is what the receiving actor’s match(...) dispatches on. That is precisely why the discriminant has to travel inside the payload. See Serialization.

The convention is kind: string, lowercase, kebab-case for multi-word. Capital-K Kind works too but isn’t what the framework uses.

import { Actor, ActorSystem, type ActorRef } from 'actor-ts';
import { match } from 'ts-pattern';
type IncrementCommand = { readonly kind: 'increment' };
type DecrementCommand = { readonly kind: 'decrement' };
type GetCommand = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
type CounterCommand = IncrementCommand | DecrementCommand | GetCommand;
class Counter extends Actor<CounterCommand> {
private count = 0;
override onReceive(command: CounterCommand): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'decrement' }, () => this.onDecrement())
.with({ kind: 'get' }, (m) => this.onGet(m))
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onDecrement(): void { this.count--; }
private onGet(m: GetCommand): void { m.replyTo.tell(this.count); }
}
const system = ActorSystem.create('counters');
const counter = system.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
// later, ask for the count via a reply-to ref — see Ask pattern.

If you add a kind: 'reset' variant to CounterCommand later, the .exhaustive() call fails to compile until you add a .with({ kind: 'reset' }, ...) arm. No runtime crash, no silently-dropped message — the compiler catches it.

Messages should be immutable once enqueued. Two reasons:

  1. The same message can be processed twice in some scenarios — restart-after-failure replays buffered messages; reliable-delivery re-sends unacknowledged ones. Mutation between deliveries leads to “phantom” state changes.
  2. Cross-process sends serialize the message at send time and deserialize at receive time. The receiver gets a copy; mutating the sender-side object after tell has no effect on the copy, but that asymmetry creates confusing bugs (the local case sees mutation, the remote case doesn’t).

Practical rules of thumb:

  • Use readonly properties (readonly kind, readonly replyTo, …). The TypeScript compiler then catches accidental writes.
  • Prefer plain objects ({ kind: 'x', n: 1 }) over classes — they serialize trivially, no method-loss across process boundaries.
  • For arrays / nested objects inside a message, treat them as deeply immutable. If you need to “update,” return a new message; don’t mutate the incoming one.

tell is fire-and-forget. For request/response, the convention is to include a replyTo: ActorRef<ReplyType> field in the request message:

type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
type ReplyOK = { readonly kind: 'reply-ok'; readonly value: number };
class Counter extends Actor<Get> {
private count = 42;
override onReceive(command: Get): void {
command.replyTo.tell(this.count);
}
}

The caller provides a ref the actor sends the reply to. Three sources for that ref:

  • this.self if the caller is another actor wanting the reply delivered back to itself.
  • this.sender if the caller is acting on a previous message and wants the reply to go to whoever asked them. Note: this.sender is Option<ActorRef> — empty when the message came from outside the actor system.
  • A throwaway “ask” probe — what the Ask pattern builds for you. Returns a Promise<Reply>.

The reply-to-ref idiom is more verbose than a method call but earns three things: requests and replies are decoupled in time (no await chain), the reply can come from a different actor than the one asked, and the same code works locally and cross-cluster.

A common shape mistake: declaring replyTo: ActorRef<number> when the actor sometimes replies with { kind: 'reply-ok' } and sometimes { kind: 'reply-error' }.

Use a discriminated union for the reply type, too:

type GetReply =
| { readonly kind: 'reply-ok'; readonly value: number }
| { readonly kind: 'reply-error'; readonly reason: string };
type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<GetReply> };

The caller’s onReceive can then match() on the reply’s kind to distinguish the success and error paths the same way it dispatches its own commands.

Command vs Event — the persistent-actor split

Section titled “Command vs Event — the persistent-actor split”

For actors that persist events (PersistentActor), the framework splits commands (what to do) from events (what already happened):

import { match } from 'ts-pattern';
type DepositCommand = { readonly kind: 'deposit'; readonly amount: number };
type Deposited = { readonly kind: 'deposited'; readonly amount: number; readonly timestamp: number };
class Account extends PersistentActor<DepositCommand, Deposited, { balance: number }> {
// command handler: validate + persist event
async onCommand(state, command: DepositCommand): Promise<void> {
await match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.exhaustive();
}
private async onDeposit(command: DepositCommand): Promise<void> {
const event: Deposited = { kind: 'deposited', amount: command.amount, timestamp: Date.now() };
await this.persist(event, () => { /* post-persist side effects */ });
}
// event handler: state update only — runs on both write and recovery
onEvent(state, event: Deposited): { balance: number } {
// A fold that computes a value — arms stay inline.
return match(event)
.with({ kind: 'deposited' }, (e) => ({ balance: state.balance + e.amount }))
.exhaustive();
}
}

The split matters because events are the durable record, replayed on restart. Commands are transient (the request that produced the event); events are eternal (the journal entry). Naming them distinctly — past-tense for events (Deposited), imperative for commands (DepositCommand) — keeps the conceptual line clear.

See PersistentActor for the full event-sourcing story.

  • Pattern matching — the match().exhaustive() idiom used in every example on this page.
  • Ask pattern — ref.ask(message, timeout) returns a Promise for the reply, avoiding the manual reply-to-ref dance.
  • Actor — the base class, the onReceive signature, the context references that produce reply refs.
  • Serialization — what happens to your messages when they cross a process / cluster boundary; how to register custom serializers when the JSON default doesn’t fit.
  • PersistentActor — where the Command-vs-Event split becomes load-bearing.