cheat sheet

Zod

TypeScript-first runtime validation library. Define schemas, validate inputs, infer static types — works at API boundaries, env config, forms, and tRPC.

Zod — TypeScript-first schema validation with static type inference

What it is

Zod is a TypeScript-first schema declaration and validation library. You define a schema (z.object({ ... })), and Zod gives you (a) a runtime validator (schema.parse(input)) and (b) a statically-inferred TypeScript type (z.infer<typeof schema>). The two are guaranteed to stay in sync because they come from the same source — eliminating the duplicated "TypeScript interface + manual validator" pattern that plagued early TS codebases. It's the standard validator for tRPC, t3-env, and React Hook Form integrations, and a default at API boundaries in Fastify, Hono, and Next.js apps. Alternatives include Valibot (smaller bundle, modular), ArkType (faster, TS-syntax-like), and Yup (older, less type-safe).

Install

bash
# npm
npm install zod

# pnpm
pnpm add zod

# yarn
yarn add zod

# bun
bun add zod

Output: (none — exits 0 on success)

Zod requires TypeScript ≥4.5 and strict: true in tsconfig.json to give you the full type inference experience:

json
{
  "compilerOptions": {
    "strict": true,
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "bundler"
  }
}

Syntax

A Zod schema is a value of type ZodType<T>. You build it with the z factory, then call .parse(input) to validate (throws on failure) or .safeParse(input) to validate without throwing.

typescript
import { z } from "zod";

const Schema = z.object({ /* ... */ });
const result = Schema.parse(input);          // throws ZodError on failure
const safe = Schema.safeParse(input);        // { success, data | error }
type Inferred = z.infer<typeof Schema>;      // static TS type

Output: (none — exits 0 on success)

Primitive schemas

The primitive builders (z.string(), z.number(), z.boolean(), z.date(), z.bigint(), z.symbol()) match their JS type and chain validation methods like .min(), .max(), .email(), .url(), .uuid(), .regex(). Each chained call returns a new schema, so order is irrelevant (except .optional()/.nullable() which wrap).

typescript
import { z } from "zod";

// Strings
const Email = z.string().email();
const Url = z.string().url();
const Slug = z.string().min(1).max(64).regex(/^[a-z0-9-]+$/);
const Uuid = z.string().uuid();
const NonEmpty = z.string().trim().min(1, "cannot be empty");

// Numbers
const Port = z.number().int().min(1).max(65_535);
const Price = z.number().positive().multipleOf(0.01);
const Percent = z.number().min(0).max(100);

// Booleans, dates, bigints
const Flag = z.boolean();
const Born = z.date().min(new Date("1900-01-01"));
const Big = z.bigint().positive();

// Literals (exact match)
const Method = z.literal("GET");
const StatusOk = z.literal(200);

// Enums
const Role = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof Role>;   // "admin" | "editor" | "viewer"

// Native enum (TS enum)
enum Status { Active = "active", Banned = "banned" }
const StatusSchema = z.nativeEnum(Status);

Email.parse("alice@example.com");   // "alice@example.com"
Email.parse("not-an-email");        // throws ZodError

Output:

text
ZodError: [
  {
    "validation": "email",
    "code": "invalid_string",
    "message": "Invalid email",
    "path": []
  }
]

Objects, arrays, records, tuples

z.object({ ... }) is the workhorse. By default Zod strips unknown keys (rather than failing or passing them through) — that behaviour is controlled by .strict(), .strip(), and .passthrough().

typescript
const User = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  name: z.string().min(1),
  role: z.enum(["admin", "user"]),
  tags: z.array(z.string()).default([]),
  createdAt: z.date(),
});

type User = z.infer<typeof User>;
// {
//   id: number;
//   email: string;
//   name: string;
//   role: "admin" | "user";
//   tags: string[];
//   createdAt: Date;
// }

// Arrays
const Names = z.array(z.string()).min(1).max(10);
const Pairs = z.array(z.tuple([z.string(), z.number()]));

// Records — homogeneous keyed objects (Record<K, V>)
const Counters = z.record(z.string(), z.number());

// Tuples — heterogeneous, fixed length
const Coord = z.tuple([z.number(), z.number(), z.number()]);

// Strict — fail on unknown keys
const StrictUser = User.strict();
StrictUser.parse({ id: 1, email: "a@b.c", name: "Alice", role: "user",
  tags: [], createdAt: new Date(), extra: "boom" });
// throws — "Unrecognized key: 'extra'"

// Passthrough — keep unknown keys
const Loose = User.passthrough();

Output:

text
ZodError: [
  {
    "code": "unrecognized_keys",
    "keys": ["extra"],
    "path": [],
    "message": "Unrecognized key: 'extra'"
  }
]

Optional, nullable, and defaults

.optional() wraps a schema as T | undefined, .nullable() as T | null, and .nullish() as T | null | undefined. .default(value) provides a fallback when the input is undefined and changes the output type to non-optional (the input type stays optional).

typescript
const Profile = z.object({
  bio: z.string().optional(),                  // string | undefined
  avatar: z.string().url().nullable(),         // string | null
  nickname: z.string().nullish(),              // string | null | undefined
  joinedAt: z.date().default(() => new Date()),
  newsletter: z.boolean().default(false),
});

type ProfileIn = z.input<typeof Profile>;
// { bio?: string; avatar: string | null; nickname?: string | null;
//   joinedAt?: Date; newsletter?: boolean }

type ProfileOut = z.infer<typeof Profile>;
// { bio?: string; avatar: string | null; nickname?: string | null;
//   joinedAt: Date; newsletter: boolean }   <-- defaults applied

Profile.parse({ avatar: null });
// { avatar: null, joinedAt: <now>, newsletter: false }

Output:

text
{ avatar: null, joinedAt: 2026-05-25T10:00:00.000Z, newsletter: false }

parse vs safeParse

parse throws a ZodError on failure and returns the validated value on success. safeParse never throws — it returns a discriminated union { success: true, data } | { success: false, error }. Use safeParse at trust boundaries (HTTP handlers, form submissions, IPC) where you want to return a 400 with diagnostics rather than crash; use parse for trusted/internal data where a failure is a bug.

typescript
const User = z.object({ id: z.number(), email: z.string().email() });

// parse: throws on failure
try {
  const user = User.parse(JSON.parse(req.body));
  // user is fully typed + validated
} catch (err) {
  if (err instanceof z.ZodError) {
    console.error(err.issues);
  }
}

// safeParse: discriminated union, no try/catch
const result = User.safeParse(JSON.parse(req.body));
if (!result.success) {
  return res.status(400).json({ errors: result.error.issues });
}
console.log(result.data);   // fully typed

Output (safeParse failure):

text
{
  success: false,
  error: ZodError {
    issues: [
      { code: 'invalid_type', expected: 'number', received: 'string',
        path: ['id'], message: 'Expected number, received string' },
      { code: 'invalid_string', validation: 'email',
        path: ['email'], message: 'Invalid email' }
    ]
  }
}

There's also parseAsync / safeParseAsync for schemas that contain async refinements (e.g. database lookups inside a .refine).

Unions and discriminated unions

z.union([...]) matches any of N schemas (tries them in order). For tagged objects, prefer z.discriminatedUnion("kind", [...]) — it's faster (Zod picks the right branch by reading the discriminant key) and produces vastly better error messages.

typescript
// Plain union — tries each branch in order
const StringOrNumber = z.union([z.string(), z.number()]);

// Discriminated union — branch picked by the literal field
const Shape = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("circle"), radius: z.number() }),
  z.object({ kind: z.literal("square"), side: z.number() }),
  z.object({ kind: z.literal("rectangle"), w: z.number(), h: z.number() }),
]);

type Shape = z.infer<typeof Shape>;
// | { kind: "circle";    radius: number }
// | { kind: "square";    side: number }
// | { kind: "rectangle"; w: number; h: number }

const s = Shape.parse({ kind: "circle", radius: 5 });
if (s.kind === "circle") {
  // TypeScript narrows automatically
  console.log(s.radius);
}

Shape.parse({ kind: "triangle", base: 3, height: 4 });
// throws — "Invalid discriminator value. Expected 'circle' | 'square' | 'rectangle'"

Output:

text
ZodError: [
  {
    "code": "invalid_union_discriminator",
    "options": ["circle", "square", "rectangle"],
    "path": ["kind"],
    "message": "Invalid discriminator value. Expected 'circle' | 'square' | 'rectangle'"
  }
]

Transformations and refinements

.transform(fn) post-processes a validated value into a new shape — the output type changes, the input stays the same. .refine(predicate, message) adds an arbitrary boolean check that produces a custom error message. .superRefine is the multi-issue variant for cases where one input may produce multiple errors.

typescript
// Transform: input string, output Date
const DateFromString = z.string()
  .datetime()
  .transform((s) => new Date(s));

type In  = z.input<typeof DateFromString>;   // string
type Out = z.infer<typeof DateFromString>;   // Date

DateFromString.parse("2026-05-25T10:00:00Z");   // Date object

// Refine: custom validation
const Password = z.string()
  .min(8, "at least 8 chars")
  .refine((s) => /[A-Z]/.test(s), "needs an uppercase letter")
  .refine((s) => /[0-9]/.test(s), "needs a digit");

// Refine across multiple fields — at the object level
const Signup = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
  message: "passwords don't match",
  path: ["confirm"],   // attach the error to a specific field
});

// SuperRefine for multiple issues
const Username = z.string().superRefine((val, ctx) => {
  if (val.length < 3) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, message: "too short" });
  }
  if (val.includes(" ")) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, message: "no spaces" });
  }
});

// Async refinement — DB uniqueness check
const UniqueEmail = z.string().email().refine(
  async (email) => !(await db.user.findUnique({ where: { email } })),
  { message: "email already taken" },
);

// Use parseAsync for schemas with async refines
await UniqueEmail.parseAsync("alice@example.com");

Output: (none — exits 0 on success)

Coercion — z.coerce

z.coerce.<type>() runs the JS coercion function (String(), Number(), Boolean(), new Date(), BigInt()) before validation. Indispensable for URL params, env vars, and form inputs that arrive as strings.

typescript
const Port = z.coerce.number().int().min(1).max(65_535);
Port.parse("3000");        // 3000 (number)
Port.parse(3000);          // 3000

const When = z.coerce.date();
When.parse("2026-05-25");  // Date object

const Flag = z.coerce.boolean();
Flag.parse("true");        // true
Flag.parse("false");       // true (!! — coerce is just `Boolean()`)
Flag.parse("");            // false
Flag.parse(0);             // false

z.coerce.boolean() calls Boolean(x), which is "truthy" — the string "false" becomes true because non-empty strings are truthy in JS. For env-var booleans, use a transform instead: z.enum(["true", "false"]).transform(v => v === "true") or z.string().transform((s) => s === "true" || s === "1").

Extracting types — z.infer and z.input

z.infer<typeof S> gives the output type (post-transform, post-default). z.input<typeof S> gives the input type (what you pass to parse). For schemas with no transforms/defaults, the two are identical.

typescript
const Schema = z.object({
  count: z.coerce.number(),
  createdAt: z.date().default(() => new Date()),
  raw: z.string().transform((s) => s.toUpperCase()),
});

type SchemaIn  = z.input<typeof Schema>;
//  { count: unknown; createdAt?: Date; raw: string }

type SchemaOut = z.infer<typeof Schema>;
//  { count: number; createdAt: Date; raw: string }

Composing schemas — extend, pick, omit, merge, partial, deepPartial

Object schemas are first-class values: you can .extend, .pick, .omit, .merge, .partial(), and .deepPartial() them. This is the Zod equivalent of TS Pick/Omit/Partial utility types — but at runtime.

typescript
const User = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string(),
  password: z.string(),
  createdAt: z.date(),
});

// Pick — narrow to a subset of fields
const PublicUser = User.pick({ id: true, email: true, name: true });

// Omit — drop fields
const SafeUser = User.omit({ password: true });

// Extend — add fields
const Admin = User.extend({ permissions: z.array(z.string()) });

// Merge — combine two object schemas
const Timestamped = z.object({ updatedAt: z.date() });
const UserWithUpdated = User.merge(Timestamped);

// Partial — all fields optional (useful for PATCH bodies)
const UserPatch = User.partial();

// Deep partial — recursive
const DeepPatch = User.deepPartial();

// Required — make optional fields required
const NoOptionals = UserPatch.required();

Output: (none — exits 0 on success)

Error handling and formatting

A ZodError exposes an issues array — one entry per validation failure with code, path, and message. Use error.format() for a nested object keyed by field path (handy for forms), or error.flatten() for a flat { fieldErrors, formErrors } shape.

typescript
const Form = z.object({
  email: z.string().email(),
  age: z.number().int().min(18),
  address: z.object({
    zip: z.string().regex(/^\d{5}$/),
  }),
});

const result = Form.safeParse({ email: "x", age: 12, address: { zip: "bad" } });

if (!result.success) {
  console.log(result.error.issues);
  console.log(result.error.flatten());
  console.log(result.error.format());
}

Output:

text
[
  { code: 'invalid_string', validation: 'email', path: ['email'], message: 'Invalid email' },
  { code: 'too_small', minimum: 18, path: ['age'], message: 'Number must be greater than or equal to 18' },
  { code: 'invalid_string', validation: 'regex', path: ['address','zip'], message: 'Invalid' }
]

{
  formErrors: [],
  fieldErrors: {
    email: ['Invalid email'],
    age: ['Number must be greater than or equal to 18'],
    address: ['Invalid input']
  }
}

{
  _errors: [],
  email:   { _errors: ['Invalid email'] },
  age:     { _errors: ['Number must be greater than or equal to 18'] },
  address: { _errors: [], zip: { _errors: ['Invalid'] } }
}

Custom messages are passed as the second arg to most builders or via .refine(predicate, message):

typescript
const Email = z.string({
  required_error: "Email is required",
  invalid_type_error: "Email must be a string",
}).email("That's not a valid email address.");

Validating environment variables

The single highest-leverage place to use Zod is at process startup: parse process.env into a typed env object and crash early if anything is missing or malformed. The standard pattern combines Zod with dotenv.

typescript
// src/env.ts
import { z } from "zod";
import "dotenv/config";   // load .env into process.env

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(32),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),

  // Booleans need a transform, not coerce (see warning above)
  FEATURE_BETA: z.string()
    .default("false")
    .transform((v) => v === "true" || v === "1"),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ Invalid environment:");
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
export type Env = z.infer<typeof EnvSchema>;
typescript
// src/server.ts
import { env } from "./env";

console.log(`Starting in ${env.NODE_ENV} on port ${env.PORT}`);
// env.PORT is `number`, env.FEATURE_BETA is `boolean`

Run with a bad .env:

bash
DATABASE_URL=not-a-url PORT=abc node --import tsx src/server.ts

Output:

text
❌ Invalid environment:
{
  DATABASE_URL: [ 'Invalid url' ],
  PORT:         [ 'Expected number, received nan' ],
  API_KEY:      [ 'Required' ]
}

Framework integrations

Fastify — schema-driven JSON validation

Fastify natively supports JSON-Schema validators on routes. Use fastify-type-provider-zod to plug Zod schemas in and get end-to-end types from request to handler.

bash
npm install fastify fastify-type-provider-zod zod

Output: (none — exits 0 on success)

typescript
import Fastify from "fastify";
import { z } from "zod";
import {
  serializerCompiler,
  validatorCompiler,
  ZodTypeProvider,
} from "fastify-type-provider-zod";

const app = Fastify().withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.post("/users", {
  schema: {
    body: z.object({
      email: z.string().email(),
      name: z.string().min(1),
    }),
    response: {
      201: z.object({ id: z.number(), email: z.string().email() }),
    },
  },
  handler: async (req, reply) => {
    // req.body is fully typed: { email: string; name: string }
    const user = await createUser(req.body);
    return reply.code(201).send(user);
  },
});

tRPC — end-to-end typed RPC

tRPC uses Zod for input validation; the inferred type flows from the schema into both the server handler and the client call.

typescript
import { z } from "zod";
import { initTRPC } from "@trpc/server";

const t = initTRPC.create();

export const appRouter = t.router({
  createUser: t.procedure
    .input(z.object({
      email: z.string().email(),
      age: z.number().int().min(18),
    }))
    .mutation(async ({ input }) => {
      // input typed { email: string; age: number }
      return createUser(input);
    }),
});

export type AppRouter = typeof appRouter;
typescript
// client side
const user = await trpc.createUser.mutate({
  email: "alice@example.com",
  age: 30,
});   // fully typed; bad inputs are caught at compile time

React Hook Form

@hookform/resolvers/zod plugs a Zod schema in as the form's validator.

bash
npm install react-hook-form @hookform/resolvers zod

Output: (none — exits 0 on success)

tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
type FormValues = z.infer<typeof Schema>;

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(Schema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
      <button type="submit">Sign in</button>
    </form>
  );
}

Comparison with other validators

AspectZodValibotArkTypeYup
API styleMethod chainingModular functions (tree-shakeable)TS-syntax-like stringsMethod chaining
Bundle size (gzipped)~13 KB~1-3 KB (per-schema)~10 KB~17 KB
Static inferenceFirst-classFirst-classFirst-classPartial
Runtime speedBaselineComparable1-100× faster (compiled)Slower
EcosystemMassive (default in tRPC, t3)GrowingNewOlder, less used
TS version required≥4.5≥5.0≥5.1≥4.0
Async refinementsYesYesNo (planned)Yes
Stable since2020202420242014

Zod is the safe default — most TS jobs in 2026 expect familiarity with it. If your app is bundle-size critical (e.g. an embedded widget) and you need only a small subset of validators, Valibot's tree-shakeable design will be noticeably smaller. If you need pure performance at scale (millions of validations per second), ArkType compiles schemas to specialised JS for big wins.

Common pitfalls

  1. Confusing z.input and z.infer — for schemas with defaults or transforms they differ. Use z.input for the type you pass to parse, z.infer (alias of z.output) for what you get back.
  2. z.coerce.boolean() for env vars — see the warning above. "false" becomes true. Use a transform: z.string().transform((v) => v === "true").
  3. .parse in HTTP handlers — throws on bad input, which (without a global error handler) leaks a stack trace. Use safeParse and return a structured 400 response.
  4. Forgetting await parseAsync for async refines — calling .parse on a schema that contains an async .refine throws synchronously. Always use parseAsync/safeParseAsync when async refinements are present.
  5. Cyclic schemas with z.object({ children: z.array(z.lazy(() => …)) }) — TS can't infer recursive types automatically. Annotate explicitly: const Tree: z.ZodType<TreeType> = z.lazy(() => …).
  6. Strict mode strips unknown keys silently — by default, z.object({}).parse({ extra: 1 }) succeeds and returns {}. If you need to reject unknowns, append .strict(). If you need to preserve them, use .passthrough().
  7. Reusing one schema across input and output — a schema with .transform produces a different output type. For DTOs, define the input schema and let z.infer produce both types from the same source rather than duplicating.
  8. Validating large blobs synchronously on a hot path — Zod is fast but not free. For Fastify routes, use fastify-type-provider-zod so validation is JIT-compiled into the route lifecycle rather than re-running per request.
  9. Treating Zod as a TS type-checker replacement — it's a runtime validator. Compile-time TS types still need to be there; Zod augments them at runtime. Don't .parse(x as any) to silence TS — fix the type first.
  10. Forgetting strict: true in tsconfig.json — without strict mode, optional/nullable inference falls back to looser types and you lose half the value.

Real-world recipes

Parse + validate an env config at startup (with dotenv)

The single most-used Zod recipe — fail fast, get a typed env object, never re-validate again.

bash
npm install zod dotenv

Output: (none — exits 0 on success)

typescript
// src/env.ts
import { z } from "zod";
import "dotenv/config";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  JWT_SECRET: z.string().min(32),
  STRIPE_KEY: z.string().startsWith("sk_"),
  SENTRY_DSN: z.string().url().optional(),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid env:", parsed.error.flatten().fieldErrors);
  process.exit(1);
}
export const env = parsed.data;
bash
node --import tsx src/server.ts

Output:

text
Invalid env: {
  DATABASE_URL: [ 'Required' ],
  JWT_SECRET:   [ 'String must contain at least 32 character(s)' ]
}

Validate JSON from fetch

A typed wrapper around fetch that validates the response body and returns typed data — no more as any at API boundaries.

typescript
import { z } from "zod";

async function fetchJson<S extends z.ZodTypeAny>(
  url: string,
  schema: S,
  init?: RequestInit,
): Promise<z.infer<S>> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`${url}${res.status}`);
  const json = await res.json();
  return schema.parse(json);
}

// Usage
const UserSchema = z.object({
  id: z.number(),
  login: z.string(),
  name: z.string().nullable(),
});

const user = await fetchJson("https://api.github.com/users/alicedev", UserSchema);
console.log(user.login);   // typed as string

Output:

text
alicedev

Discriminated union for an event stream

A WebSocket / SSE event handler that narrows on the type field automatically.

typescript
import { z } from "zod";

const Event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("chat"),   userId: z.number(), text: z.string() }),
  z.object({ type: z.literal("join"),   userId: z.number(), name: z.string() }),
  z.object({ type: z.literal("leave"),  userId: z.number() }),
  z.object({ type: z.literal("typing"), userId: z.number(), at: z.coerce.date() }),
]);
type Event = z.infer<typeof Event>;

socket.onmessage = (raw) => {
  const result = Event.safeParse(JSON.parse(raw.data));
  if (!result.success) {
    console.warn("Dropping malformed event", result.error.issues);
    return;
  }
  const ev = result.data;
  switch (ev.type) {
    case "chat":   render(`${ev.userId}: ${ev.text}`); break;
    case "join":   render(`${ev.name} joined`); break;
    case "leave":  render(`${ev.userId} left`); break;
    case "typing": showTypingIndicator(ev.userId, ev.at); break;
  }
};

A PATCH endpoint with partial schemas

Reuse one canonical schema for create + update by deriving the PATCH body from .partial().

typescript
import { z } from "zod";

const User = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().min(0).max(150),
});

const CreateUser = User;
const UpdateUser = User.partial();   // every field optional

// POST /users — full body required
app.post("/users", async (req, res) => {
  const body = CreateUser.parse(req.body);
  res.json(await db.user.create({ data: body }));
});

// PATCH /users/:id — any subset
app.patch("/users/:id", async (req, res) => {
  const body = UpdateUser.parse(req.body);
  if (Object.keys(body).length === 0) {
    return res.status(400).json({ error: "no fields to update" });
  }
  res.json(await db.user.update({ where: { id: +req.params.id }, data: body }));
});

Output: (none — exits 0 on success)

Form validation with custom messages

Per-field, per-rule error messages for a signup form.

typescript
const Signup = z.object({
  email: z.string({ required_error: "Email is required" })
           .email("That's not a valid email address"),
  password: z.string()
             .min(8, "At least 8 characters")
             .regex(/[A-Z]/, "Needs an uppercase letter")
             .regex(/[0-9]/, "Needs a digit"),
  confirm: z.string(),
  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms" }),
  }),
}).refine((d) => d.password === d.confirm, {
  message: "Passwords don't match",
  path: ["confirm"],
});

const result = Signup.safeParse({
  email: "x",
  password: "abc",
  confirm: "abd",
  acceptTerms: false,
});

if (!result.success) {
  console.log(result.error.flatten().fieldErrors);
}

Output:

text
{
  email:       [ "That's not a valid email address" ],
  password:    [ 'At least 8 characters', 'Needs an uppercase letter', 'Needs a digit' ],
  confirm:     [ "Passwords don't match" ],
  acceptTerms: [ 'You must accept the terms' ]
}

Generating JSON Schema for OpenAPI

zod-to-json-schema converts a Zod schema to a JSON-Schema document — useful for OpenAPI generation, public-facing API docs, or feeding to LLMs that expect JSON-Schema tool definitions.

bash
npm install zod-to-json-schema

Output: (none — exits 0 on success)

typescript
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const User = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
});

console.log(JSON.stringify(zodToJsonSchema(User, "User"), null, 2));

Output:

json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$ref": "#/definitions/User",
  "definitions": {
    "User": {
      "type": "object",
      "properties": {
        "id": { "type": "integer", "exclusiveMinimum": 0 },
        "email": { "type": "string", "format": "email" },
        "role": { "type": "string", "enum": ["admin", "user"] }
      },
      "required": ["id", "email", "role"],
      "additionalProperties": false
    }
  }
}