Patterns

Patterns

Schema-first type inference

When to use: whenever a runtime-validated type and its TypeScript type must stay in sync. How: define the schema, then derive type X = z.infer<typeof X> — never hand-write a parallel interface. Trade-offs: the schema becomes the single source of truth; if input/output diverge (transforms, codecs), use z.input<>/z.output<> instead of z.infer<> alone.

Bidirectional conversion via codecs

When to use: parsing at a network/storage boundary where the same shape must later be serialized back (ISO string ↔ Date, JSON string ↔ object). How: z.codec(inputSchema, outputSchema, { decode, encode }); .parse()/.decode() go forward, .encode() goes backward, z.invertCodec() flips direction. Trade-offs: more setup than a one-way .transform(), but the only correct choice when the value must round-trip; .transform() anywhere in a schema makes .encode() throw.

Reference-and-narrow generics for library code

When to use: writing a function that accepts "any Zod schema" without losing its specific subclass. How: function f<T extends z4.$ZodType>(schema: T), never (schema: z4.$ZodType<T>); narrow further with <T extends z4.$ZodObject> or <T extends z4.$ZodType<SomeOutput>> as needed. Trade-offs: slightly more verbose signatures, but preserves caller type information that a naive generic parameter would erase.

Object composition via spread, not chained .extend()

When to use: merging multiple object schemas, especially in a chain. How: z.object({ ...A.shape, ...B.shape, extra: z.string() }) instead of A.extend({...}).extend({...}). Trade-offs: identical behavior in Zod and Zod Mini, avoids the quadratic tsc cost of chained .extend(), but loses .extend()'s automatic strictness-level inheritance — pick z.strictObject()/z.looseObject() explicitly.

Discriminated unions for tagged variants

When to use: a union of object shapes that all share one literal "kind"/"status"/"type" field. How: z.discriminatedUnion("status", [objA, objB, ...]) instead of z.union([...]). Trade-offs: faster parsing and better error messages than a naive union; requires every option to be an object schema with the same literal-valued key.

Exhaustive vs. partial dictionaries

When to use: a Record<K, V> keyed by a fixed set of literal/enum values. How: z.record(enumKeys, valueSchema) for "every key must be present" (matches TS Record<K,V>); z.partialRecord(enumKeys, valueSchema) when only some keys may appear. Trade-offs: defaulting to z.record() on enum keys silently requires every key — a common migration surprise for anyone expecting partial dictionaries.

Layered error customization

When to use: building consistent, localized, field-aware error messages across an application. How: set a locale globally (z.config(en())) as the baseline, add a global customError map for app-wide conventions, override per-schema with the error param for field-specific messages, and reach for per-parse error maps only for one-off call-site overrides. Trade-offs: precedence runs check > schema > per-parse > global > locale — forgetting this order leads to "why isn't my override working" bugs when a more specific layer already set a message.

Metadata-driven documentation & JSON Schema

When to use: schemas that need to double as OpenAPI/JSON Schema definitions or AI structured-output specs. How: attach .meta({ id, title, description }) (registers in z.globalRegistry) at schema-definition time, then call z.toJSONSchema(schema) (single schema) or z.toJSONSchema(z.globalRegistry) (interlinked, $ref-based multi-schema output) when needed. Trade-offs: metadata fields override Zod's generated JSON Schema keywords, which is powerful but means metadata mistakes propagate directly into the generated spec.

Recursive schemas via getters

When to use: self-referential or mutually-recursive object types (trees, linked records). How: define the recursive field as get field() { return SchemaThatReferencesMe } instead of a plain property. Trade-offs: works reliably only for object-referencing-object patterns; mixing in unions or nested function calls inside the getter often breaks TypeScript's inference and needs an explicit return-type annotation.

Choosing Zod vs. Zod Mini

When to use: default to full zod; switch to zod/mini only under measured, strict frontend bundle-size constraints. How: both packages implement identical behavior against the shared zod/v4/core; library code that must support both builds exclusively against "zod/v4/core" types. Trade-offs: Mini's functional .check()-based API is more verbose and less autocomplete-friendly; the bundle savings are real (~60-70% smaller) but usually irrelevant on backends or over typical network latency.