A lightweight, type-safe Actor Model implementation for TypeScript/JavaScript.
An actor is a message-driven stateful computation unit. It processes messages sequentially through a mailbox, ensuring safe state mutations without complex async coordination.
Key benefits:
- Sequential processing: Messages are queued and processed one at a time (FIFO)
- No async race conditions: Concurrent sends are serialized automatically
- Reactive subscriptions: Subscribe to state changes
- Error isolation: Handler errors don't break the mailbox
- Framework-agnostic: Works with any UI framework or Node.js
# Deno
deno add jsr:@marianmeres/actor# npm
npm install @marianmeres/actorimport { createStateActor } from "@marianmeres/actor";
// Define message types
type Message =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "ADD"; payload: number };
// Create a counter actor
const counter = createStateActor<number, Message>(0, (state, msg) => {
switch (msg.type) {
case "INCREMENT": return state + 1;
case "DECREMENT": return state - 1;
case "ADD": return state + msg.payload;
}
});
// Send messages
await counter.send({ type: "INCREMENT" });
await counter.send({ type: "ADD", payload: 10 });
console.log(counter.getState()); // 11
// Subscribe to changes (with optional previous state, Svelte-compatible)
const unsubscribe = counter.subscribe(({ current, previous }) => {
console.log("Counter:", current, "(was:", previous, ")");
});
// Cleanup
counter.destroy();For complete API documentation, see API.md.
| Function | Description |
|---|---|
createStateActor(initial, handler, options?) |
Simple actor where handler returns the new state |
createActor(options) |
Full control with optional reducer, onError, debug, logger |
defineMessage(type) |
Creates typed message factories (like Redux action creators) |
createTypedStateActor(initial, handlers, options?) |
Exhaustive message handling with compile-time checking |
createTypedActor(options) |
Full control + exhaustive handlers + reducer + debug logging |
createMessageFactory() |
DTOKit factory for validating external messages |
All actor factories support optional debug: true and custom logger for verbose debug logging.
| Actor Method | Description |
|---|---|
send(msg) |
Queue message, returns Promise<TResponse> |
subscribe(fn) |
Subscribe to state changes. Callback receives { current, previous? } - called immediately with undefined previous, then on each change with actual previous state. Svelte-compatible single parameter. |
getState() |
Get current state synchronously |
destroy() |
Clear mailbox and subscribers |
debug |
Read-only debug flag value (boolean | undefined) |
logger |
Read-only logger instance (custom or console) |
The send() method uses a "deferred promise" pattern internally to bridge the gap between
when you call send() and when your message is actually processed:
send(message) {
return new Promise((resolve, reject) => {
mailbox.push({ message, resolve, reject }); // Store callbacks with message
processMailbox();
});
}Why this design?
-
Callbacks stored with message: Each message is queued alongside its own
resolve/rejectfunctions. WhenprocessMailboxeventually processes this specific message, it can resolve/reject the correct caller's promise. -
Non-blocking queue trigger:
processMailbox()is called after every push, but has a guard (if (processing) return) so it's a no-op when already running. Thewhileloop inside ensures all queued messages are processed sequentially before releasing the lock. -
Async by design: Even if your handler is synchronous,
send()returns a Promise because:- Your message may wait behind others in the queue (FIFO order)
- The handler might be async (I/O, network calls)
- Callers need to know when their message was processed and get the result
This pattern enables the core actor guarantee: sequential message processing with async-friendly callers.
The reducer option separates what the handler returns from how state is updated.
Without reducer (createStateActor): handler's return value IS the new state.
With reducer: handler can return rich data, reducer extracts what goes into state:
const actor = createActor<
number, // State type
string, // Message type
{ delta: number; log: string } // Response type (different from state!)
>({
initialState: 0,
handler: (state, msg) => {
return { delta: msg.length, log: `Processed: ${msg}` };
},
reducer: (state, response) => state + response.delta,
});
const result = await actor.send("hello");
// result = { delta: 5, log: "Processed: hello" } ← caller gets full response
// state = 5 ← but state only stores deltaUse cases: async operations returning { data, metadata }, validation returning
{ valid, errors }, side effects returning status while reducer decides what to persist.
Subscribers receive { current, previous } as a single parameter (Svelte-compatible), enabling powerful change detection patterns:
const user = createStateActor<
{ name: string; email: string; preferences: object },
UpdateMessage
>(initialState, handler);
user.subscribe(({ current, previous }) => {
// On initial subscription, previous is undefined
if (previous === undefined) {
console.log("Initial state loaded");
return;
}
// React only to specific property changes
if (current.email !== previous.email) {
sendEmailVerification(current.email);
}
if (current.preferences !== previous.preferences) {
syncPreferencesToServer(current.preferences);
}
});Common use cases for previous state:
- Conditional side effects (only run when specific fields change)
- Computing diffs for analytics or debugging
- Animations/transitions between states
- Undo/redo without external history tracking
Good fit:
- Complex async workflows needing serialization
- Shared state with multiple async writers
- WebSocket/SSE message handling
- Form submission with validation
- Background task queues
- Undo/redo stacks
- Game state machines
Probably overkill:
- Simple component state (use signals/stores instead)
- Synchronous state updates only
- Single-writer scenarios
This library intentionally implements a single-actor pattern without actor addresses, hierarchies, or spawning capabilities. This is a deliberate design choice.
In the traditional Actor Model (Erlang/Elixir OTP, Akka), actors have:
- Addresses - unique identifiers allowing actors to send messages to each other
- Spawning - actors can create child actors and receive their addresses
- Supervision - parent actors monitor and restart failed children
These features are essential for building distributed, fault-tolerant systems with many coordinating actors. However, they add significant complexity.
A focused, lightweight primitive for the most common use case: serialized message processing with encapsulated state. Each actor is self-contained and handles:
- Sequential (FIFO) message processing via mailbox
- Safe state mutations without complex async coordination
- Reactive subscriptions for state changes
- Error isolation within the handler
If your use case requires actors communicating with each other, building actor hierarchies, or distributed fault-tolerance, consider:
- xstate - State machines with actor spawning
- Comlink - Web Workers as actors
- A backend runtime like Elixir/Erlang for true distributed actors
For most frontend and simple backend scenarios, this single-actor approach provides the core benefits (serialization, isolation, reactivity) without the cognitive overhead of a full actor system.
For actors with multiple message types, the optional @marianmeres/dtokit integration provides compile-time exhaustive checking - TypeScript will error if you forget to handle any message type.
With standard createStateActor, forgetting a message type in your switch statement
produces no TypeScript error:
type Message =
| { type: "INC" }
| { type: "DEC" }
| { type: "ADD"; amount: number };
const counter = createStateActor<number, Message>(0, (state, msg) => {
switch (msg.type) {
case "INC": return state + 1;
case "DEC": return state - 1;
// Oops! Forgot "ADD" - TypeScript doesn't complain!
}
});With createTypedStateActor, missing handlers cause compile-time errors:
import { createTypedStateActor } from "@marianmeres/actor";
// Define schemas: keys MUST match the "type" discriminator values
type Schemas = {
INC: { type: "INC" };
DEC: { type: "DEC" };
ADD: { type: "ADD"; amount: number };
};
const counter = createTypedStateActor<Schemas, number>(0, {
INC: (msg, state) => state + 1,
DEC: (msg, state) => state - 1,
// TypeScript ERROR: Property 'ADD' is missing in type...
});- Single source of truth: Define message types once in a schemas object
- Exhaustive handlers: Provide a handler for each schema key (not a switch statement)
- Type inference: Each handler receives the correctly-typed message
- Compile-time safety: Add a new message type → compiler shows where to add handlers
| Aspect | createStateActor |
createTypedStateActor |
|---|---|---|
| Message definition | Union type: { type: "A" } | { type: "B" } |
Schema object: { A: {...}, B: {...} } |
| Handler style | switch (msg.type) |
Handler object: { A: fn, B: fn } |
| Missing case | No error (silent bug) | Compile error |
| Adding message | Edit union + add case | Edit schema + add handler (compiler guides you) |
| Refactoring | Manual find/replace | Compiler catches all locations |
import { createTypedStateActor, createMessageFactory } from "@marianmeres/actor";
// 1. Define your message schemas (single source of truth)
type TodoSchemas = {
ADD: { type: "ADD"; text: string };
REMOVE: { type: "REMOVE"; id: number };
TOGGLE: { type: "TOGGLE"; id: number };
CLEAR_DONE: { type: "CLEAR_DONE" };
};
type Todo = { id: number; text: string; done: boolean };
// 2. Create actor with exhaustive handlers
const todos = createTypedStateActor<TodoSchemas, Todo[]>([], {
ADD: (msg, state) => [...state, { id: Date.now(), text: msg.text, done: false }],
REMOVE: (msg, state) => state.filter(t => t.id !== msg.id),
TOGGLE: (msg, state) => state.map(t =>
t.id === msg.id ? { ...t, done: !t.done } : t
),
CLEAR_DONE: (_, state) => state.filter(t => !t.done),
});
// 3. Full type safety on send()
await todos.send({ type: "ADD", text: "Buy milk" });
await todos.send({ type: "TOGGLE", id: 123 });
// await todos.send({ type: "TYPO" }); // TypeScript error!
// 4. Optional: Validate external messages (WebSocket, API, etc.)
const factory = createMessageFactory<TodoSchemas>();
websocket.onmessage = (event) => {
const msg = factory.parse(JSON.parse(event.data));
if (msg) {
todos.send(msg); // Type-safe after validation!
}
};Use createTypedActor when handlers return data different from state:
import { createTypedActor } from "@marianmeres/actor";
type Schemas = {
PROCESS: { type: "PROCESS"; data: string };
RESET: { type: "RESET" };
};
type State = { result: string | null };
type Response = { result: string; metadata: { processedAt: number } };
const processor = createTypedActor<Schemas, State, Response>({
initialState: { result: null },
handlers: {
PROCESS: (msg, state) => ({
result: msg.data.toUpperCase(),
metadata: { processedAt: Date.now() }
}),
RESET: () => ({ result: "", metadata: { processedAt: 0 } }),
},
reducer: (state, response) => ({ result: response.result }),
});
const response = await processor.send({ type: "PROCESS", data: "hello" });
// response.result = "HELLO"
// response.metadata.processedAt = 1701234567890
// processor.getState() = { result: "HELLO" }Use createTypedStateActor when:
- You have 3+ message types
- Team members add new message types
- You want refactoring safety
- Messages come from external sources needing validation
Stick with createStateActor when:
- Simple actor with 1-2 message types
- You're prototyping quickly
- You prefer switch statement style
The createTypedStateActor, createTypedActor, and createMessageFactory functions use
@marianmeres/dtokit internally for exhaustive
type checking. This dependency is included with the package - no additional installation needed.
// All exports from single entry point
import {
createStateActor, // Basic actor
createTypedStateActor, // With exhaustive checking
createMessageFactory, // For external message validation
} from "@marianmeres/actor";Some examples below use the amazing @marianmeres/fsm library. Pure coincidence.
interface DataState {
data: unknown | null;
loading: boolean;
error: string | null;
}
const fetcher = createActor<DataState, { type: "FETCH"; url: string }, DataState>({
initialState: { data: null, loading: false, error: null },
handler: async (state, msg) => {
const res = await fetch(msg.url);
const data = await res.json();
return { data, loading: false, error: null };
},
reducer: (_, response) => response,
});
// Multiple rapid fetches are serialized - no race conditions!
fetcher.send({ type: "FETCH", url: "/api/data" });
fetcher.send({ type: "FETCH", url: "/api/data" });
// Responses arrive in order, one at a timeA common DDD pattern: use a finite state machine to coordinate multiple domain actors. Each actor manages its own domain state, while the FSM orchestrates the workflow.
import { createTypedStateActor } from "@marianmeres/actor";
import { createFsm } from "@marianmeres/fsm";
// Domain actors - each manages its own bounded context
type CartSchemas = {
ADD_ITEM: { type: "ADD_ITEM"; item: { sku: string; qty: number } };
CLEAR: { type: "CLEAR" };
};
const cart = createTypedStateActor<CartSchemas, { items: Array<{ sku: string; qty: number }>; total: number }>(
{ items: [], total: 0 },
{
ADD_ITEM: (msg, state) => ({ ...state, items: [...state.items, msg.item] }),
CLEAR: () => ({ items: [], total: 0 }),
}
);
type PaymentSchemas = {
PROCESS: { type: "PROCESS" };
SUCCESS: { type: "SUCCESS"; txId: string };
FAIL: { type: "FAIL" };
};
const payment = createTypedStateActor<PaymentSchemas, { status: string; txId: string | null }>(
{ status: "idle", txId: null },
{
PROCESS: () => ({ status: "processing", txId: null }),
SUCCESS: (msg) => ({ status: "success", txId: msg.txId }),
FAIL: () => ({ status: "failed", txId: null }),
}
);
type InventorySchemas = {
RESERVE: { type: "RESERVE"; items: Array<{ sku: string; qty: number }> };
RELEASE: { type: "RELEASE" };
};
const inventory = createTypedStateActor<InventorySchemas, { reserved: Array<{ sku: string; qty: number }> }>(
{ reserved: [] },
{
RESERVE: (msg, state) => ({ reserved: [...state.reserved, ...msg.items] }),
RELEASE: () => ({ reserved: [] }),
}
);
// FSM orchestrates the checkout workflow
const checkoutFsm = createFsm({
initial: "IDLE",
context: { error: null },
states: {
IDLE: {
on: { start: "RESERVING" }
},
RESERVING: {
onEnter: async (ctx) => {
await inventory.send({ type: "RESERVE", items: cart.getState().items });
checkoutFsm.transition("reserved");
},
on: { reserved: "CHARGING", fail: "FAILED" }
},
CHARGING: {
onEnter: async (ctx) => {
await payment.send({ type: "PROCESS" });
// Simulate payment processing
const success = Math.random() > 0.1;
if (success) {
await payment.send({ type: "SUCCESS", txId: "tx_123" });
checkoutFsm.transition("charged");
} else {
await payment.send({ type: "FAIL" });
checkoutFsm.transition("fail");
}
},
on: { charged: "COMPLETED", fail: "ROLLING_BACK" }
},
ROLLING_BACK: {
onEnter: async () => {
await inventory.send({ type: "RELEASE" });
checkoutFsm.transition("rolled_back");
},
on: { rolled_back: "FAILED" }
},
COMPLETED: {
onEnter: async () => {
await cart.send({ type: "CLEAR" });
}
},
FAILED: {}
}
});
// Usage: subscribe to FSM for workflow state, actors for domain state
checkoutFsm.subscribe(({ state }) => console.log("Checkout:", state));
payment.subscribe((s) => console.log("Payment:", s.status));// User preferences actor
const preferences = createStateActor(
{ email: true, push: true, sms: false },
(state, msg) => ({ ...state, [msg.channel]: msg.enabled })
);
// Notification queue actor - serializes delivery
const notificationQueue = createActor({
initialState: { pending: 0, sent: 0 },
handler: async (state, msg) => {
const prefs = preferences.getState();
const results = [];
if (prefs.email && msg.channels.includes("email")) {
await sendEmail(msg.to, msg.content);
results.push("email");
}
if (prefs.push && msg.channels.includes("push")) {
await sendPush(msg.to, msg.content);
results.push("push");
}
return { delivered: results, messageId: msg.id };
},
reducer: (state, response) => ({
pending: state.pending - 1,
sent: state.sent + response.delivered.length
})
});
// FSM controls notification campaign lifecycle
const campaignFsm = createFsm({
initial: "DRAFT",
context: { recipientCount: 0, sentCount: 0 },
states: {
DRAFT: { on: { schedule: "SCHEDULED", send: "SENDING" } },
SCHEDULED: { on: { trigger: "SENDING", cancel: "DRAFT" } },
SENDING: {
onEnter: async (ctx) => {
for (const recipient of ctx.recipients) {
await notificationQueue.send({
id: crypto.randomUUID(),
to: recipient,
content: ctx.message,
channels: ["email", "push"]
});
ctx.sentCount++;
}
campaignFsm.transition("complete");
},
on: { complete: "COMPLETED", pause: "PAUSED" }
},
PAUSED: { on: { resume: "SENDING" } },
COMPLETED: {}
}
});In DDD, the aggregate root coordinates changes within a bounded context:
import { createTypedStateActor } from "@marianmeres/actor";
// Define the domain types
type Item = { sku: string; qty: number; price: number };
type Payment = { amount: number; method: string };
type Shipment = { trackingId: string; carrier: string };
type OrderState = {
id: string;
status: "pending" | "submitted" | "paid" | "shipped";
items: Item[];
payments: Payment[];
shipments: Shipment[];
};
// Define all order commands (message schemas)
type OrderSchemas = {
ADD_ITEM: { type: "ADD_ITEM"; item: Item };
SUBMIT: { type: "SUBMIT" };
RECORD_PAYMENT: { type: "RECORD_PAYMENT"; payment: Payment };
SHIP: { type: "SHIP"; shipment: Shipment };
};
// Order aggregate - the actor IS the aggregate root
const createOrderActor = (orderId: string) => createTypedStateActor<OrderSchemas, OrderState>(
{
id: orderId,
status: "pending",
items: [],
payments: [],
shipments: []
},
{
ADD_ITEM: (msg, state) => {
if (state.status !== "pending") return state; // Invariant
return { ...state, items: [...state.items, msg.item] };
},
SUBMIT: (_, state) => {
if (state.items.length === 0) return state; // Invariant
return { ...state, status: "submitted" };
},
RECORD_PAYMENT: (msg, state) => ({
...state,
payments: [...state.payments, msg.payment],
status: calculateStatus(state.payments, msg.payment)
}),
SHIP: (msg, state) => {
if (state.status !== "paid") return state; // Invariant
return { ...state, shipments: [...state.shipments, msg.shipment] };
},
}
);
// Each order is an independent actor maintaining its own invariants
const order1 = createOrderActor("order-1");
const order2 = createOrderActor("order-2");
// Messages to different orders process independently
await order1.send({ type: "ADD_ITEM", item: { sku: "A", qty: 2, price: 10 } });
await order2.send({ type: "ADD_ITEM", item: { sku: "B", qty: 1, price: 25 } });Use onError to propagate actor failures to the FSM layer:
// FSM with error handling - payload stored via onEnter
const workflowFsm = createFsm({
initial: "IDLE",
context: { error: null },
states: {
IDLE: { on: { start: "PROCESSING" } },
PROCESSING: { on: { done: "COMPLETED", error: "ERROR" } },
COMPLETED: {},
ERROR: {
onEnter: (ctx, payload) => { ctx.error = payload; } // Store error from payload
}
}
});
// Actor with onError wired to FSM
const processor = createActor({
initialState: { result: null },
handler: async (state, msg) => {
const res = await fetch(msg.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { result: await res.json() };
},
reducer: (_, response) => response,
onError: (error, message) => {
// Pass error as transition payload
workflowFsm.transition("error", { error: String(error), message });
}
});
// Usage
workflowFsm.transition("start");
await processor.send({ url: "/api/data" }); // If this throws, FSM → ERROR
workflowFsm.transition("done"); // Only reached on successEach actor can have its own internal FSM for complex lifecycle management, while a global FSM orchestrates multiple actors at the application level.
import { createActor } from "@marianmeres/actor";
import { createFsm } from "@marianmeres/fsm";
// Factory: creates a "job" actor with its own internal FSM lifecycle
const createJobActor = (id: string, work: () => Promise<unknown>) => {
// Inner FSM - manages this job's lifecycle
const jobFsm = createFsm({
initial: "IDLE",
context: { result: null, error: null },
states: {
IDLE: { on: { start: "RUNNING" } },
RUNNING: { on: { complete: "DONE", fail: "FAILED" } },
DONE: {},
FAILED: { on: { retry: "RUNNING" } } // Can retry from failed
}
});
// Actor wraps the FSM and handles async work
const actor = createActor<
{ id: string; status: string; result: unknown; error: unknown },
{ type: "START" } | { type: "RETRY" },
{ status: string }
>({
initialState: { id, status: "IDLE", result: null, error: null },
handler: async (state, msg) => {
if (msg.type === "START" || msg.type === "RETRY") {
jobFsm.transition(msg.type === "START" ? "start" : "retry");
try {
const result = await work();
jobFsm.transition("complete");
jobFsm.context.result = result;
return { status: "DONE" };
} catch (e) {
jobFsm.transition("fail");
jobFsm.context.error = e;
return { status: "FAILED" };
}
}
return { status: jobFsm.state };
},
reducer: (state, response) => ({
...state,
status: response.status,
result: jobFsm.context.result,
error: jobFsm.context.error
})
});
return { actor, fsm: jobFsm };
};
// Create job actors with different work
const job1 = createJobActor("job-1", async () => {
await delay(100);
return { data: "result-1" };
});
const job2 = createJobActor("job-2", async () => {
await delay(200);
if (Math.random() < 0.3) throw new Error("Random failure");
return { data: "result-2" };
});
// App-level FSM - orchestrates the batch of jobs
const batchFsm = createFsm({
initial: "IDLE",
context: { completed: 0, failed: 0, total: 2 },
states: {
IDLE: { on: { run: "RUNNING" } },
RUNNING: {
onEnter: async (ctx) => {
// Start all jobs in parallel
const results = await Promise.all([
job1.actor.send({ type: "START" }),
job2.actor.send({ type: "START" })
]);
// Tally results
results.forEach(r => {
if (r.status === "DONE") ctx.completed++;
else ctx.failed++;
});
batchFsm.transition(ctx.failed > 0 ? "partial" : "success");
},
on: { success: "COMPLETED", partial: "PARTIAL_FAILURE" }
},
PARTIAL_FAILURE: {
on: {
retry: "RETRYING",
accept: "COMPLETED"
}
},
RETRYING: {
onEnter: async (ctx) => {
// Retry only failed jobs
const failedJobs = [job1, job2].filter(j => j.fsm.state === "FAILED");
for (const job of failedJobs) {
const result = await job.actor.send({ type: "RETRY" });
if (result.status === "DONE") {
ctx.failed--;
ctx.completed++;
}
}
batchFsm.transition(ctx.failed > 0 ? "partial" : "success");
},
on: { success: "COMPLETED", partial: "PARTIAL_FAILURE" }
},
COMPLETED: {}
}
});
// Subscribe to see the layered state
batchFsm.subscribe(({ state }) => console.log("Batch:", state));
job1.actor.subscribe(s => console.log("Job1:", s.status));
job2.actor.subscribe(s => console.log("Job2:", s.status));
// Run the batch
batchFsm.transition("run");Track state changes across multiple actors and FSM for debugging, audit, or undo/redo:
// Historian actor - records all state changes across the system
const historian = createStateActor<
{ entries: Array<{ ts: number; source: string; state: unknown }> },
{ type: "RECORD"; source: string; state: unknown } | { type: "CLEAR" }
>(
{ entries: [] },
(state, msg) => {
switch (msg.type) {
case "RECORD":
return {
entries: [...state.entries, { ts: Date.now(), source: msg.source, state: msg.state }]
};
case "CLEAR":
return { entries: [] };
default:
return state;
}
}
);
// Helper to wire up any actor to the historian
const track = <T>(name: string, actor: { subscribe: (fn: (s: T) => void) => void }) => {
actor.subscribe((state) => historian.send({ type: "RECORD", source: name, state }));
};
// Domain actors
const cart = createStateActor({ items: [] }, (state, msg) => { /* ... */ });
const payment = createStateActor({ status: "idle" }, (state, msg) => { /* ... */ });
// Wire them up
track("cart", cart);
track("payment", payment);
// Also track FSM transitions
const checkoutFsm = createFsm({ /* ... */ });
checkoutFsm.subscribe(({ state }) => {
historian.send({ type: "RECORD", source: "checkout-fsm", state });
});
// Now historian.getState().entries contains full system history:
// [
// { ts: 1701234567890, source: "cart", state: { items: [] } },
// { ts: 1701234567891, source: "payment", state: { status: "idle" } },
// { ts: 1701234567892, source: "checkout-fsm", state: "IDLE" },
// { ts: 1701234567900, source: "cart", state: { items: [{ sku: "A" }] } },
// { ts: 1701234567901, source: "checkout-fsm", state: "RESERVING" },
// ...
// ]MIT