Zod Types
Master every Zod primitive and composite type - strings, numbers, arrays, objects, enums, and unions.
Search across all documentation pages
Master every Zod primitive and composite type - strings, numbers, arrays, objects, enums, and unions.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
// Primitives
const str = z.string().min(1).max(255);
const num = z.number().int().positive();
const bool = z.boolean();
const date = z.date();
const bigint = z.bigint();
// Strings with built-in validators
const email = z.string().email();
const url = z.string().url();
const uuid = z.string().uuid();
const cuid = z.string().cuid();
const regex = z.string().regex(/^[A-Z]{3}-\d{4}$/);
// Numbers
const price = z.number().min(0).max(999999).multipleOf(0.01);
const port = z.number().int().gte(1024).lte(65535);
// Arrays
const tags = z.array(z.string()).min(1).max(10);
const uniqueTags = z.array(z.string()).nonempty();
// Objects
const User = z.object({
id: z.string().uuid(),
name: z.string(),
role: z.enum(["admin", "user", "guest"]),
});
// Enums
const Status = z.enum(["active", "inactive", "pending"]);
const NativeEnum = z.nativeEnum(MyEnum); // works with TS enums
// Unions & discriminated unions
const Result = z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), data: z.string() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
// Optionals, nullables, defaults
const optional = z.string().optional(); // string | undefined
const nullable = z.string().nullable(); // string | null
const nullish = z.string().nullish(); // string | null | undefined
const withDefault = z.string().default("N/A");When to reach for this: When you need to define the shape and constraints of any data structure for validation.
"use client";
import { useState } from "react";
import { z } from "zod";
const ProductSchema = z.object({
name: z.string().min(1, "Product name required").max(100),
price: z.coerce.number().positive("Price must be positive"),
category: z.enum(["electronics", "clothing", "food", "other"]),
tags: z.array(z.string().min(1)).min(1, "At least one tag").max(5),
metadata: z
.object({
weight: z.coerce.number().positive().optional(),
color: z.string().optional(),
})
.optional(),
});
type Product = z.infer<typeof ProductSchema>;
export function ProductForm() {
const [result, setResult] = useState<string>("");
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const raw = {
name: fd.get("name"),
price: fd.get("price"),
category: fd.get("category"),
tags: (fd.get("tags") as string)?.split(",").map((t) => t.trim()),
metadata: {
weight: fd.get("weight") || undefined,
color: fd.get("color") || undefined,
},
};
const parsed = ProductSchema.safeParse(raw);
if (parsed.success) {
setResult(JSON.stringify(parsed.data, null, 2));
} else {
setResult(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"));
}
}
return (
<form onSubmit={handleSubmit} className="flex max-w-md flex-col gap-3">
<input name="name" placeholder="Product name" className="rounded border p-2" />
<input name="price" placeholder="Price" type="number" step="0.01" className="rounded border p-2" />
<select name="category" className="rounded border p-2">
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="food">Food</option>
<option value="other">Other</option>
</select>
<input name="tags" placeholder="Tags (comma-separated)" className="rounded border p-2" />
<input name="weight" placeholder="Weight (optional)" type="number" className="rounded border p-2" />
<input name="color" placeholder="Color (optional)" className="rounded border p-2" />
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">Validate</button>
{result && <pre className="rounded bg-gray-50 p-3 text-sm whitespace-pre-wrap">{result}</pre>}
</form>
);
}What this demonstrates:
z.coerce.number() for form string-to-number conversionz.enum()ZodType and implements a _parse method internallyz.string().min(1).email() creates a new schema at each stepz.coerce.* calls the native constructor (Number(), String(), Boolean()) before validatingz.enum() accepts a readonly tuple of string literals and infers a union typez.discriminatedUnion() uses a shared key to dispatch to the correct branch efficientlyz.object() is strict about the shape - use .extend(), .merge(), .pick(), .omit() to derive new shapesExtending and merging objects:
const Base = z.object({ id: z.string().uuid(), createdAt: z.date() });
const WithName = Base.extend({ name: z.string() });
const Merged = Base.merge(z.object({ email: z.string().email() }));
const Picked = Base.pick({ id: true });
const Omitted = Base.omit({ createdAt: true });
const Partial = Base.partial(); // all fields optional
const Required = Partial.required(); // all fields required again
const DeepPartial = Base.deepPartial(); // nested objects also partialRecord and Map types:
const Config = z.record(z.string(), z.number());
// { [key: string]: number }
const Scores = z.map(z.string(), z.number());
// Map<string, number>Tuples and literal types:
const Coord = z.tuple([z.number(), z.number()]);
const Tagged = z.tuple([z.literal("point"), z.number(), z.number()]);
const WithRest = z.tuple([z.string()]).rest(z.number());
// [string, ...number[]]Lazy schemas for recursive types:
type Category = { name: string; children: Category[] };
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema),
})
);// z.enum produces a union type
const Role = z.enum(["admin", "user"]);
type Role = z.infer<typeof Role>; // "admin" | "user"
Role.enum.admin; // "admin" - typed constant access
Role.options; // ["admin", "user"] - the tuple
// nativeEnum works with TypeScript enums
enum Direction { Up = "UP", Down = "DOWN" }
const DirSchema = z.nativeEnum(Direction);
type Dir = z.infer<typeof DirSchema>; // Direction
// Discriminated unions infer correctly
type Result = z.infer<typeof Result>;
// { status: "ok"; data: string } | { status: "error"; message: string }z.enum requires as const - If you pass a plain array, TypeScript widens the type to string[]. Fix: Use z.enum(["a", "b"] as const) or define the array with as const first.
z.coerce.date() accepts numbers - z.coerce.date() calls new Date(value), so it accepts timestamps and arbitrary numbers. Fix: If you want ISO strings only, use z.string().datetime() instead.
z.object is not z.record - z.object requires exact keys; z.record allows any keys of a given type. Mixing them up leads to incorrect validation.
Union vs discriminatedUnion - z.union tries each branch sequentially and returns the first match. z.discriminatedUnion uses a key field for O(1) dispatch and gives better error messages. Fix: Prefer z.discriminatedUnion when your objects share a type field.
| Alternative | Use When | Don't Use When |
|---|---|---|
| TypeScript type guards | Simple runtime narrowing without a library | You need declarative schemas with error messages |
| io-ts | You want functional-programming-style codec composition | You prefer a simpler, more imperative API |
| Superstruct | You need struct-based validation with custom coercion | You want built-in .email(), .url() validators |
| JSON Schema (Ajv) | You need cross-language schema interop | You want tight TypeScript inference |
.email(), .url(), .uuid(), .cuid(), .regex(), .min(), .max(), .length(), .startsWith(), .endsWith(), .trim(), .datetime(), and .ip().
z.enum(["a", "b"]) accepts a tuple of string literals and infers a union typez.nativeEnum(MyEnum) works with TypeScript enum declarationsz.enum for new code; use z.nativeEnum for existing TS enumsz.union tries each branch sequentially and returns the first matchz.discriminatedUnion uses a shared key field for O(1) dispatch and better error messagesz.discriminatedUnion when objects share a type/status fieldconst Base = z.object({ id: z.string(), name: z.string() });
const Extended = Base.extend({ email: z.string().email() });
const Picked = Base.pick({ id: true });
const Omitted = Base.omit({ id: true });
const Partial = Base.partial();
const DeepPartial = Base.deepPartial();const Config = z.record(z.string(), z.number());
// Inferred type: { [key: string]: number }This is different from z.object(), which requires exact keys.
const WithRest = z.tuple([z.string()]).rest(z.number());
// Inferred type: [string, ...number[]]type Category = { name: string; children: Category[] };
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema),
})
);You must declare the type manually because TypeScript cannot infer recursive types from z.lazy.
z.coerce.number() calls Number(value) on the input before validating. This converts strings like "42" to 42. Plain z.number() rejects strings.
TypeScript widens the array type to string[], losing the literal types. Fix by using as const:
const roles = ["admin", "user"] as const;
const Role = z.enum(roles); // "admin" | "user"z.coerce.date() calls new Date(value), which accepts timestamps and numbers. new Date(999) is a valid date. If you want only ISO strings, use z.string().datetime() instead.
.optional() allows undefined.nullable() allows null.default(value) makes the field optional in input but provides a fallback in outputconst Role = z.enum(["admin", "user"]);
Role.enum.admin; // "admin" (typed constant)
Role.options; // ["admin", "user"] (the tuple)
type Role = z.infer<typeof Role>; // "admin" | "user"It produces a proper union type where each branch is narrowed by the discriminant key:
type Result = { status: "ok"; data: string } | { status: "error"; message: string };TypeScript can narrow the type by checking the status field.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥