Server Fields
State data sometimes contains fields that must never reach the client — API keys, SSNs, internal scores. Declare a second clientSchema alongside the state's full schema, and the framework uses it to produce client-safe snapshots and client TypeScript types.
The approach is validator-agnostic — both schemas are plain Standard Schema validators. The framework never inspects their shape; stripping happens through each validator's own default-strip behaviour.
Declaring Server and Client Schemas
Wrap a state in state() with schema (full/server-side) and clientSchema (client-safe). Both are ordinary validator instances.
const loanDef = defineWorkflow("loan", {
states: {
Review: state({
schema: z.object({
applicantName: z.string(),
ssn: z.string(),
creditScore: z.number(),
}),
clientSchema: z.object({
applicantName: z.string(),
}),
}),
Approved: state({
schema: z.object({
applicantName: z.string(),
approvedAmount: z.number(),
underwriterNotes: z.string(),
}),
clientSchema: z.object({
applicantName: z.string(),
approvedAmount: z.number(),
}),
}),
},
commands: {
Approve: z.object({ amount: z.number() }),
},
events: {
LoanApproved: z.object({ loanId: z.string() }),
},
errors: {
CreditCheckFailed: z.object({ reason: z.string() }),
},
});States without sensitive fields can still be plain schemas — state() is only needed when the server and client views diverge.
Serializing for Clients
serialize() always returns the full snapshot for server-side persistence. serializeForClient() runs data through clientSchema.validate() and returns the result — unknown keys (those absent from clientSchema) are stripped by the validator's default behaviour.
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 — data passes through the client schema; unknown keys are 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));
}Strict validators: Zod and Valibot strip unknown keys by default; this "just works." ArkType is strict by default — if you use ArkType for
clientSchema, write it in loose mode (e.g.type({ "+": "ignore", applicantName: "string" })) orserializeForClient()will throw.
Client Definitions
definition.forClient() returns a ClientWorkflowDefinition — a projection that validates incoming snapshots against the declared clientSchema. This restores defence-in-depth: a corrupted or malicious client-side snapshot is rejected before you reconstruct the workflow.
const clientDef = loanDef.forClient();
// Re-validates against the declared clientSchema for defence-in-depth
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.
When a state has no clientSchema, deserialize() passes data through unchanged (state-name check only) — there's no client/server divergence to validate.
Type Safety
ClientStateData infers from clientSchema when declared, falling back to the server schema otherwise. 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: the clientSchema's inferred output
type ReviewClient = ClientStateData<LoanConfig, "Review">;
// { applicantName: string }
const data: ReviewClient = { applicantName: "Alice" };
data.applicantName; // ✅ string
// @ts-expect-error — ssn is only in the server schema
data.ssn;
// @ts-expect-error — creditScore is only in the server schema
data.creditScore;Edge Cases
- No
clientSchemadeclared —serializeForClient()returns the same data asserialize(). Adoption is incremental — a state with no server/client divergence can stay as a plain schema. - Empty
clientSchema— Zod/Valibotobject({})strips everything, yielding{}. Client knows the workflow's state but not the data. - Nested sensitive fields — express them inside the clientSchema explicitly (e.g.
clientSchema: z.object({ applicant: z.object({ name: z.string() }) })to keepapplicant.namebut dropapplicant.ssn). The consumer controls the client shape entirely.
Migration from the server() wrapper
Earlier versions exported a server(schema) function that branded Zod schemas inline, and a later iteration used a server: ["key1", "key2"] key list. Both approaches were replaced by the two-schema model, which avoids introspecting schema shape:
// Old (removed): inline brand
Review: z.object({
applicantName: z.string(),
ssn: server(z.string()),
})
// Old (removed): key list
Review: state({
schema: z.object({ applicantName: z.string(), ssn: z.string() }),
server: ["ssn"],
})
// Current: two schemas
Review: state({
schema: z.object({ applicantName: z.string(), ssn: z.string() }),
clientSchema: z.object({ applicantName: z.string() }),
})Client types (ClientStateData<Config, "Review">) and runtime output are equivalent after the change, but the current form makes the client contract explicit and expressible in any validator that supports Standard Schema.