Zod Types
Master every Zod primitive and composite type — strings, numbers, arrays, objects, enums, and unions.
Recipe
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.
Working Example
"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:
- Coercion with
z.coerce.number()for form string-to-number conversion - Enum validation with
z.enum() - Nested optional objects
- Array validation from comma-separated input
Deep Dive
How It Works
- Every Zod type extends
ZodTypeand implements a_parsemethod internally - Validators chain immutably —
z.string().min(1).email()creates a new schema at each step z.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 shapes
Variations
Extending 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),
})
);TypeScript Notes
// 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 }Gotchas
-
z.enumrequiresas const— If you pass a plain array, TypeScript widens the type tostring[]. Fix: Usez.enum(["a", "b"] as const)or define the array withas constfirst. -
z.coerce.date()accepts numbers —z.coerce.date()callsnew Date(value), so it accepts timestamps and arbitrary numbers. Fix: If you want ISO strings only, usez.string().datetime()instead. -
z.objectis notz.record—z.objectrequires exact keys;z.recordallows any keys of a given type. Mixing them up leads to incorrect validation. -
Union vs discriminatedUnion —
z.uniontries each branch sequentially and returns the first match.z.discriminatedUnionuses a key field for O(1) dispatch and gives better error messages. Fix: Preferz.discriminatedUnionwhen your objects share a type field.
Alternatives
| 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 |
FAQs
What are the built-in string validators available in Zod?
.email(), .url(), .uuid(), .cuid(), .regex(), .min(), .max(), .length(), .startsWith(), .endsWith(), .trim(), .datetime(), and .ip().
What is the difference between z.enum and z.nativeEnum?
z.enum(["a", "b"])accepts a tuple of string literals and infers a union typez.nativeEnum(MyEnum)works with TypeScriptenumdeclarations- Prefer
z.enumfor new code; usez.nativeEnumfor existing TS enums
What is the difference between z.union and z.discriminatedUnion?
z.uniontries each branch sequentially and returns the first matchz.discriminatedUnionuses a shared key field for O(1) dispatch and better error messages- Prefer
z.discriminatedUnionwhen objects share a type/status field
How do you extend or derive new object schemas from an existing one?
const 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();How do you define a record type (dynamic keys)?
const Config = z.record(z.string(), z.number());
// Inferred type: { [key: string]: number }This is different from z.object(), which requires exact keys.
How do you define a tuple with a rest element?
const WithRest = z.tuple([z.string()]).rest(z.number());
// Inferred type: [string, ...number[]]How do you create a recursive schema (e.g., a tree structure)?
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.
What does z.coerce.number() do differently from z.number()?
z.coerce.number() calls Number(value) on the input before validating. This converts strings like "42" to 42. Plain z.number() rejects strings.
Gotcha: Why does z.enum fail when you pass a plain array variable?
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"Gotcha: Why does z.coerce.date() accept arbitrary numbers like 999?
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.
What is the difference between .optional(), .nullable(), and .default()?
.optional()allowsundefined.nullable()allowsnull.default(value)makes the field optional in input but provides a fallback in output
TypeScript: How does z.enum provide typed constant access?
const 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"TypeScript: How does z.discriminatedUnion infer its type?
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.
Related
- Zod Basics — parse, safeParse, error handling
- Zod Transforms — transform, refine, preprocess
- Zod Infer — deriving TypeScript types
- Server Action Forms — Zod in server actions