Server Fields
State data sometimes contains fields that must never reach the client — API keys, SSNs, internal scores. The server() marker declares fields as server-only, and the framework strips them at serialization time and excludes them from client TypeScript types.
Marking Fields
Wrap any Zod schema with server() to mark it as server-only. It works at any nesting depth within z.object():
const loanDef = defineWorkflow("loan", {
states: {
Review: z.object({
applicantName: z.string(),
ssn: server(z.string()),
creditScore: server(z.number()),
}),
Approved: z.object({
applicantName: z.string(),
approvedAmount: z.number(),
underwriterNotes: server(z.string()),
}),
},
commands: {
Approve: z.object({ amount: z.number() }),
},
events: {
LoanApproved: z.object({ loanId: z.string() }),
},
errors: {
CreditCheckFailed: z.object({ reason: z.string() }),
},
});The original schema is not mutated — server() returns a new reference, so shared schemas are safe.
Serializing for Clients
serialize() always returns the full snapshot for server-side persistence. serializeForClient() strips server fields from the data:
const wf = loanDef.createWorkflow("loan-1", {
initialState: "Review",
data: { applicantName: "Alice", ssn: "123-45-6789", creditScore: 780 },
});
// Full snapshot — for server-side persistence
const full = loanDef.serialize(wf);
// full.data = { applicantName: "Alice", ssn: "123-45-6789", creditScore: 780 }
// Client snapshot — server fields stripped
const client = loanDef.serializeForClient(wf);
// client.data = { applicantName: "Alice" }A typical integration pattern persists the full snapshot and broadcasts the stripped one:
if (result.ok) {
// Persist full snapshot
await storage.put(definition.serialize(result.workflow));
// Broadcast stripped snapshot to clients
broadcast(definition.serializeForClient(result.workflow));
}Client Definitions
definition.forClient() returns a ClientWorkflowDefinition — a client-safe projection where state schemas have server fields removed. Its deserialize() validates against the stripped schemas:
const clientDef = loanDef.forClient();
// Client schemas have server fields removed
const result = clientDef.deserialize(client);
if (result.ok) {
result.workflow.state; // "Review"
result.workflow.data; // { applicantName: "Alice" }
}
// Same instance on repeated calls
loanDef.forClient() === clientDef; // trueThe client definition is memoized — forClient() returns the same instance on repeated calls.
Type Safety
ClientStateData omits server fields at compile time. Client code that tries to access a server-only field gets a compile error:
type LoanConfig = typeof loanDef.config;
// Server-side: full data type
// StateData<LoanConfig, "Review"> = { applicantName: string, ssn: string, creditScore: number }
// Client-side: server fields excluded
type ReviewClient = ClientStateData<LoanConfig, "Review">;
// { applicantName: string }
const data: ReviewClient = { applicantName: "Alice" };
data.applicantName; // ✅ string
// @ts-expect-error — ssn does not exist on client type
data.ssn;
// @ts-expect-error — creditScore does not exist on client type
data.creditScore;Edge Cases
- No
server()fields —serializeForClient()returns the same data asserialize(). Adoption is incremental. - All fields
server()— client sees{}. It knows the workflow's state but not the data. - Arrays and non-objects —
server()only applies toz.object()fields. To hide an entire array, wrap it:items: server(z.array(z.string())).