Skip to content

Error Handling

Every dispatch() returns a DispatchResult -- a discriminated union you check with result.ok.

ts
const result = await router.dispatch(workflow, command);

if (result.ok) {
  // result.workflow -- updated snapshot
  // result.events   -- emitted events
} else {
  // result.error -- PipelineError
}

Error Categories

PipelineError is a discriminated union with five categories.

Validation Errors

Zod validation failed. The source field tells you where:

SourceWhen
"command"Command payload doesn't match its schema
"state"update() produced invalid state data
"transition"transition() data doesn't match target
"event"emit() data doesn't match event schema
"restore"deserialize() data doesn't match state schema

Note: The "restore" source only appears from definition.deserialize(), not from dispatch().

ts
if (!result.ok && result.error.category === "validation") {
  console.log(result.error.source);  // "command" | "state" | "event" | "transition"
  console.log(result.error.issues);  // z.core.$ZodIssue[]
  console.log(result.error.message); // human-readable summary
}

Domain Errors

Business rule violations defined upfront in the workflow definition. Each error code has a Zod schema, making your failure modes part of the workflow's contract:

ts
const orderWorkflow = defineWorkflow("order", {
	states: {
		Created: z.object({ total: z.number() }),
		Paid: z.object({ total: z.number(), paidAt: z.coerce.date() }),
	},
	commands: {
		Pay: z.object({ amount: z.number() }),
		Ship: z.object({}),
	},
	events: {
		OrderPaid: z.object({ orderId: z.string() }),
	},
	errors: {
		InsufficientPayment: z.object({ required: z.number(), received: z.number() }),
		AlreadyShipped: z.object({}),
	},
});

Handlers raise them via error():

ts
orderRouter.state("Created", ({ on }) => {
	on("Pay", ({ command, data, error, transition, emit, workflow }) => {
		if (command.payload.amount < data.total) {
			error({
				code: "InsufficientPayment",
				data: {
					required: data.total,
					received: command.payload.amount,
				},
			});
		}
		transition("Paid", { total: data.total, paidAt: new Date() });
		emit({ type: "OrderPaid", data: { orderId: workflow.id } });
	});
});

Domain errors carry a typed code and data, validated against the error schema defined in the workflow:

ts
if (!result.ok && result.error.category === "domain") {
  console.log(result.error.code); // "InsufficientPayment"
  console.log(result.error.data); // { required: 100, received: 50 }
}

Unexpected Errors

When a handler throws a value that is neither a domain error nor a validation error, the dispatch catches it and returns an "unexpected" error instead of letting the exception propagate:

ts
if (!result.ok && result.error.category === "unexpected") {
  console.log(result.error.message); // human-readable summary
  console.log(result.error.error);   // the original thrown value
}

pipeline:end always fires even when an unexpected error occurs, so hooks and plugins that observe pipeline:end will always run.

Dependency Errors

When a dependency injected via the router constructor throws during dispatch, the error is automatically caught and returned as a "dependency" error. This lets you distinguish infrastructure failures (database down, API timeout) from handler bugs ("unexpected").

Dependencies are wrapped in a Proxy by default — no handler code changes required.

ts
if (!result.ok && result.error.category === "dependency") {
	console.log(result.error.name);    // top-level dep key, e.g. "db"
	console.log(result.error.message); // 'Dependency "db" failed: Connection refused'
	console.log(result.error.error);   // the original thrown error
}

To disable dependency wrapping:

ts
const router = new WorkflowRouter(definition, deps, { wrapDeps: false });

With wrapping disabled, dependency errors fall through to "unexpected".

Router Errors

The router itself couldn't find a handler.

CodeWhen
"NO_HANDLER"No handler registered for this state + command
"UNKNOWN_STATE"Workflow's state isn't in the definition
ts
if (!result.ok && result.error.category === "router") {
  console.log(result.error.code);    // "NO_HANDLER" | "UNKNOWN_STATE"
  console.log(result.error.message); // human-readable description
}

Rollback on Error

All mutations are provisional. If dispatch fails for any reason, the original workflow object is unchanged.

ts
(async () => {
	const task = taskWorkflow.createWorkflow("task-1", {
		initialState: "Todo",
		data: { title: "Original" },
	});

	const result = await taskRouter.dispatch(task, { type: "Start", payload: { assignee: "x" } });

	if (!result.ok) {
		console.log(task.state); // still "Todo"
		console.log(task.data.title); // still "Original"
	}
})();

The router works on internal copies. On error, those copies are discarded.

Narrowing Error Types

Use the category field to narrow and access category-specific fields:

ts
function handleResult(result: DispatchResult<ConfigOf<typeof orderRouter>>) {
	if (!result.ok) {
		switch (result.error.category) {
			case "validation":
				console.log("Validation failed:", result.error.source);
				for (const issue of result.error.issues) {
					console.log(`  - ${issue.message}`);
				}
				break;
			case "domain":
				console.log("Business rule:", result.error.code);
				break;
			case "router":
				console.log("Router:", result.error.message);
				break;
			case "dependency":
				console.log("Dependency failed:", result.error.name);
				break;
			case "unexpected":
				console.log("Unexpected:", result.error.message);
				break;
		}
	}
}