Quick Answer

Zod is a schema library for TypeScript. You write one schema - z.object({ email: z.email(), age: z.number() }) - and get two things from it: a validator that checks unknown data at runtime with parse() or safeParse(), and a static type via z.infer that always stays in sync with the schema. It is the standard way to validate API responses, form input, and environment variables.

Why runtime validation matters

TypeScript checks types while you write code, then erases every one of them before the code runs. That is fine for values you create yourself, but dangerous for data that arrives from outside:

const res = await fetch("/api/user");
const user = (await res.json()) as User;
console.log(user.name.toUpperCase());

res.json() returns any. The as User is not a check - it is you telling the compiler to stop asking questions. If the API renamed name to fullName, returned an error object, or sent null, this code compiles cleanly and then crashes at toUpperCase, several functions away from the real problem.

The same gap exists for req.body in an Express route, process.env, localStorage.getItem, JSON.parse, query-string params, and webhook payloads. All of them are any or string, and all of them can be wrong. Zod lets you check the actual shape at that boundary - once - and work with trusted, typed data everywhere after it.

Your first schema: parse vs safeParse

Install with npm install zod, then build a schema and run data through it:

import { z } from "zod";

const Email = z.email();

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

parse returns the validated, fully typed value or throws a ZodError. When bad input is expected - a form, an HTTP request - you usually want safeParse instead, which never throws:

const result = Email.safeParse("not-an-email");

if (!result.success) {
  console.log(result.error.issues[0].message); // "Invalid email address"
} else {
  console.log(result.data); // the clean value
}

The rule of thumb: use safeParse at boundaries where invalid data is a normal case you must handle, and parse where invalid data means a bug you want to fail loudly - validating config at startup, for instance, where crashing is the correct response to a missing variable.

Objects, arrays, and the inferred type

Compose schemas for real payloads:

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

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

z.infer derives the TypeScript type from the schema, so there is exactly one definition to keep correct. Change the schema and the type follows.

One subtlety trips people up: z.infer gives you the output type - the shape after parsing. Because role has a .default(), it is optional when you pass data in but always present when Zod hands it back. If you need the pre-parse shape - to type the argument a caller provides, say - use z.input<typeof User>. For most uses z.infer (an alias for z.output) is what you want.

Extra keys are silently dropped

By default, z.object removes any key that is not in the schema. It does not raise an error:

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

const clean = User.parse({ id: 1, name: "Asha", isAdmin: true });
// clean is { id: 1, name: "Asha" } - isAdmin is gone

This is usually a feature: a client cannot smuggle an isAdmin or role field into your create-user endpoint, because it never survives parsing. But beginners often expect the extra key to be rejected and are surprised when it just disappears.

If you want unknown keys to be an error, call .strict():

const Strict = User.strict();
Strict.safeParse({ id: 1, name: "Asha", isAdmin: true });
// success: false, issue code "unrecognized_keys",
// message: 'Unrecognized key: "isAdmin"'

And .passthrough() keeps unknown keys untouched. Use the default (strip) for request bodies, and .strict() when you validate something like a config file where a typo should be caught.

Custom rules and the coercion traps

.refine() adds a check that plain type rules cannot express, like comparing two fields:

const Signup = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine((data) => data.password === data.confirm, {
  message: "Passwords do not match",
  path: ["confirm"], // attach the error to the confirm field
});

Now the coercion trap. z.number() rejects the string "25", which is a problem because form fields and environment variables are always strings. z.coerce.number() converts first - but coercion uses JavaScript's own rules, and those have sharp edges:

z.coerce.number().parse("");        // 0    - Number("") is 0
z.coerce.number().parse("abc");     // throws (NaN)
z.coerce.boolean().parse("false");  // true - any non-empty string is truthy
z.coerce.boolean().parse("");       // false

z.coerce.boolean() is almost never what you want for an env flag like FEATURE_X=false - it returns true. Use z.stringbool() (it understands "true"/"false", "1"/"0", "yes"/"no") or z.enum(["true", "false"]).transform((v) => v === "true") instead.

Turning errors into useful messages

On a failed safeParse, result.error.issues is an array of { path, message, code } objects - one per problem. For a form, flatten() reshapes them into something you can render directly:

const parsed = Signup.safeParse(req.body);

if (!parsed.success) {
  return res.status(400).json(parsed.error.flatten().fieldErrors);
  // {
  //   password: ["Too small: expected string to have >=8 characters"],
  //   confirm: ["Passwords do not match"]
  // }
}
const data = parsed.data; // typed and safe from here down

Set your own messages as the second argument to any check: z.string().min(1, "Name is required"), z.email("Enter a valid email"). The default messages are clear enough for a server log but rarely what you want to show a user.

This pattern - safeParse at the top of a handler, return flatten() on failure, use parsed.data everywhere below - is the whole point of Zod. Past that line, the data is a known shape and TypeScript knows it too.

Frequently Asked Questions

Is Zod worth it for a small project? Yes, anywhere external data enters the program. One schema replaces a page of hand-written typeof checks and keeps the TypeScript type in sync automatically.
Should I use Zod 3 or Zod 4? Zod 4 is current and what npm install zod gives you. Most basics are identical; v4 adds top-level helpers like z.email() and z.url(), though the older z.string().email() still works.
Does Zod slow down my app? Validation costs microseconds for typical payloads and runs at boundaries - one request, one form submit - not in hot loops. The cost is negligible next to a single database call.
How is Zod different from a TypeScript type or interface? A TypeScript type is checked at compile time and then gone. A Zod schema checks real values at runtime. You need both: types for your own code, Zod for data you did not create.
Can I generate a Zod schema from an existing TypeScript type? Not directly - Zod is schema-first, so you write the schema and get the type from z.infer. Tools exist to convert JSON Schema or OpenAPI specs to Zod, but the intended flow is schema then type.