Defining Workflows
defineWorkflow() creates a workflow definition from a name and Zod schema configuration.
Basic Definition
const taskWorkflow = defineWorkflow("task", {
states: {
Todo: z.object({ title: z.string(), priority: z.number().default(0) }),
InProgress: z.object({ title: z.string(), assignee: z.string() }),
Done: z.object({ title: z.string(), completedAt: z.coerce.date() }),
},
commands: {
Start: z.object({ assignee: z.string() }),
Complete: z.object({}),
Rename: z.object({ title: z.string() }),
},
events: {
TaskStarted: z.object({ taskId: z.string(), assignee: z.string() }),
TaskCompleted: z.object({ taskId: z.string() }),
},
errors: {
AlreadyAssigned: z.object({ currentAssignee: z.string() }),
NotAssigned: z.object({}),
DeadlinePassed: z.object({ deadline: z.coerce.date() }),
},
});All four config keys -- states, commands, events, errors -- are required. Use {} for any you don't need yet.
Defining Errors
Errors represent domain failures that handlers can raise. Define them upfront with Zod schemas so both the error code and its data are type-safe:
errors: {
AlreadyAssigned: z.object({ currentAssignee: z.string() }),
NotAssigned: z.object({}),
DeadlinePassed: z.object({ deadline: z.coerce.date() }),
},Handlers raise errors with error(), which halts execution and rolls back all mutations:
router.state("Todo", ({ on }) => {
on("Start", ({ command, error, transition, data }) => {
if (!command.payload.assignee) {
error({ code: "NotAssigned", data: {} });
}
// only runs if no error was raised
transition("InProgress", { title: data.title, assignee: command.payload.assignee });
});
});The caller gets a typed error back:
(async () => {
const result = await router.dispatch(taskForDispatch, {
type: "Start",
payload: { assignee: "alice" },
});
if (!result.ok && result.error.category === "domain") {
result.error.code; // "AlreadyAssigned" | "NotAssigned" | "DeadlinePassed"
result.error.data; // typed based on the code
}
})();Defining errors upfront makes your workflow's failure modes explicit and discoverable -- they're part of the contract, not hidden inside handler logic.
Creating Workflow Instances
createWorkflow() instantiates a workflow in a specific initial state. The data is validated against the state's schema.
const task = taskWorkflow.createWorkflow("task-1", {
initialState: "Todo",
data: { title: "Write docs", priority: 0 },
});
console.log(task.id); // "task-1"
console.log(task.state); // "Todo"
console.log(task.data); // { title: "Write docs", priority: 0 }Zod defaults apply -- priority defaults to 0 since we used .default(0) in the schema.
If the data doesn't match the schema, createWorkflow() throws:
// Throws: Invalid initial data for state 'Todo': Required
taskWorkflow.createWorkflow("bad", {
initialState: "Todo",
// @ts-expect-error — intentionally missing required 'title' to show runtime validation
data: {},
});Schema Accessors
The definition exposes methods to retrieve individual schemas at runtime:
taskWorkflow.getStateSchema("Todo"); // ZodObject for Todo state
taskWorkflow.getCommandSchema("Start"); // ZodObject for Start command
taskWorkflow.getEventSchema("TaskStarted"); // ZodObject for TaskStarted event
taskWorkflow.getErrorSchema("AlreadyAssigned"); // ZodObject for errorEach throws if the name doesn't exist.
Checking State Existence
taskWorkflow.hasState("Todo"); // true
taskWorkflow.hasState("unknown"); // falseComplete 3-State Example
const articleWorkflow = defineWorkflow("article", {
states: {
Draft: z.object({ title: z.string(), body: z.string().optional() }),
Review: z.object({
title: z.string(),
body: z.string(),
reviewerId: z.string(),
}),
Published: z.object({
title: z.string(),
body: z.string(),
publishedAt: z.coerce.date(),
}),
},
commands: {
UpdateDraft: z.object({
title: z.string().optional(),
body: z.string().optional(),
}),
SubmitForReview: z.object({ reviewerId: z.string() }),
Approve: z.object({}),
},
events: {
DraftUpdated: z.object({ articleId: z.string() }),
SubmittedForReview: z.object({
articleId: z.string(),
reviewerId: z.string(),
}),
ArticlePublished: z.object({ articleId: z.string() }),
},
errors: {
BodyRequired: z.object({}),
},
});This definition can be used with a WorkflowRouter to handle each command -- see Routing Commands.