Recipes assume import { z } from "zod". Each shape below appears in the repo's own test suite — the cited file is where its behaviour is pinned down.

An object with optional and nullable fields

The distinction: optional() means the key may be absent; nullable() means the value may be null. This schema is the first fixture in the object tests:

packages/zod/src/v4/classic/tests/object.test.ts
const Test = z.object({
  f1: z.number(),
  f2: z.string().optional(),
  f3: z.string().nullable(),
  f4: z.array(z.object({ t: z.union([z.string(), z.boolean()]) })),
});

Get the TypeScript type out

type Test = z.infer<typeof Test>;
// { f1: number; f2?: string | undefined; f3: string | null; f4: { t: string | boolean }[] }

For schemas that transform, z.input<typeof S> and z.output<typeof S> name the two sides separately.

Validate without throwing

safeParse returns a discriminated result instead of throwing — the pattern for anything user-facing:

const result = Test.safeParse(data);
if (!result.success) {
  console.error(z.prettifyError(result.error));
} else {
  result.data; // fully typed
}

The result type is { success: true; data: T } | { success: false; error: ZodError } (defined in packages/zod/src/v4/classic/parse.ts), so narrowing on success narrows data too. What each issue inside the error means: ../../reference/error-reference/.

A tagged union, matched by its discriminator

discriminatedUnion checks the tag first and only runs the matching branch — both faster and better error messages than a plain union. From the tests:

packages/zod/src/v4/classic/tests/discriminated-unions.test.ts
z.discriminatedUnion("type", [
  z.object({ type: z.literal("a"), a: z.string() }),
  z.object({ type: z.literal("b"), b: z.string() }),
]).parse({ type: "a", a: "abc" });

Defaults, refinements, transforms

// A default applies when the key is absent or undefined
const Settings = z.object({ theme: z.string().default("dark") });

// A refinement adds a custom rule (issue code: "custom")
const Even = z.number().refine((n) => n % 2 === 0, { error: "must be even" });

// A transform changes the output type — input stays string, output becomes number
const Port = z.string().transform((s) => Number.parseInt(s, 10));

Behaviour for each lives in default.test.ts, refine.test.ts, and transform.test.ts alongside the fixtures above. How transforms ride through the parse pipeline: ../../how-it-works/parse-pipeline/.

Coerce environment variables

const Env = z.object({
  PORT: z.coerce.number().default(3000),
});
Env.parse(process.env);

z.coerce.* constructors (from packages/zod/src/v4/classic/coerce.ts) run the JavaScript coercion first, then validate the result — which is what process.env's all-string values need. One caution: z.coerce.boolean() uses Boolean(x), so the string "false" coerces to true — for boolean flags, prefer an explicit z.enum(["true", "false"]).transform((v) => v === "true").

Sources: packages/zod/src/v4/classic/tests/object.test.ts, packages/zod/src/v4/classic/tests/discriminated-unions.test.ts · last synced 2026-08-10 · 2d90846 · version 4.4.3