React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zodtypesstringsnumbersarraysobjectsenumsunions

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 ZodType and implements a _parse method 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 validating
  • z.enum() accepts a readonly tuple of string literals and infers a union type
  • z.discriminatedUnion() uses a shared key to dispatch to the correct branch efficiently
  • z.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 partial

Record 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.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 numbersz.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.recordz.object requires exact keys; z.record allows any keys of a given type. Mixing them up leads to incorrect validation.

  • Union vs discriminatedUnionz.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.

Alternatives

AlternativeUse WhenDon't Use When
TypeScript type guardsSimple runtime narrowing without a libraryYou need declarative schemas with error messages
io-tsYou want functional-programming-style codec compositionYou prefer a simpler, more imperative API
SuperstructYou need struct-based validation with custom coercionYou want built-in .email(), .url() validators
JSON Schema (Ajv)You need cross-language schema interopYou 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 type
  • z.nativeEnum(MyEnum) works with TypeScript enum declarations
  • Prefer z.enum for new code; use z.nativeEnum for existing TS enums
What is the difference between z.union and z.discriminatedUnion?
  • z.union tries each branch sequentially and returns the first match
  • z.discriminatedUnion uses a shared key field for O(1) dispatch and better error messages
  • Prefer z.discriminatedUnion when 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() allows undefined
  • .nullable() allows null
  • .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.