Skip to content

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():

ts
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:

ts
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:

typescript
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:

ts
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; // true

The 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:

ts
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() fieldsserializeForClient() returns the same data as serialize(). Adoption is incremental.
  • All fields server() — client sees {}. It knows the workflow's state but not the data.
  • Arrays and non-objectsserver() only applies to z.object() fields. To hide an entire array, wrap it: items: server(z.array(z.string())).