zod
Zod is a TypeScript-first schema validation library. You define a schema once (an object, a string with constraints, a union) and Zod gives you two things from that single definition: runtime validation of untrusted data and a static TypeScript type via z.infer. That one-source-of-truth trick is why it sits at the boundary of almost every serious TS codebase now: API inputs, env vars, form data, LLM outputs. Zero dependencies, works in Node and browsers, and since v4 it can also emit JSON Schema.
The default choice for runtime validation in TypeScript, and deservedly so. Just check that everything in your stack speaks Zod 4 before upgrading, and reach for zod/mini or valibot when shipping validation to browsers.
Use it if
- You have untrusted data crossing a boundary (HTTP body, env vars, webhook payloads, LLM JSON output) and want the parsed result to be a real TypeScript type, not a cast
- You use tRPC, react-hook-form, or an OpenAPI generator; Zod is the default schema language for most of that ecosystem
- You need to turn one schema definition into validation, TS types, and JSON Schema (z.toJSONSchema is built in as of v4)
- You want zero dependencies and no build step or codegen
- Bundle size is critical on the client: importing the full zod package costs about 60 KB gzipped; valibot or zod/mini do the same job in a fraction of that for simple cases
- Your dependencies still expect Zod 3: the v3-to-v4 migration split the ecosystem for a while, and older form resolvers or codegen tools may pin v3, giving you two zods in one tree
- You only need compile-time types and never validate at runtime; plain TypeScript interfaces are free
- You validate millions of objects per second on a hot path; ahead-of-time compilers like typia or ajv with codegen are measurably faster
Setup reality
npm install zod and you are done; zero dependencies and no config. The real friction points: TypeScript strict mode is effectively required or inference degrades; the v3-to-v4 split means some ecosystem packages (form resolvers, OpenAPI generators) lag on v4 support, and you may end up importing the zod/v3 compatibility subpath to satisfy them; and a bare import of the full package is around 60 KB gzipped, so client-side code that cares should use zod/mini, which has a tree-shakeable functional API but different call syntax.
Patterns
Define a schema and parse untrusted dataobject-schema
import * as z from "zod";
const User = z.object({
username: z.string().min(3),
xp: z.number().int().nonnegative(),
});
const data = User.parse(input); // throws ZodError on bad input
// data is typed { username: string; xp: number }parse returns a validated deep clone of the input, not the original object; unknown keys are stripped by default.
Validate without try/catchsafe-parse
const result = User.safeParse(input);
if (!result.success) {
console.log(result.error.issues); // array of { path, code, message }
} else {
result.data; // typed and validated
}The result is a discriminated union; TypeScript narrows data/error correctly after the success check.
Extract the TypeScript type from a schemainfer-types
const User = z.object({ username: z.string(), xp: z.number() });
type User = z.infer<typeof User>;
// when transforms change the type, input and output differ:
const S = z.string().transform((v) => v.length);
type In = z.input<typeof S>; // string
type Out = z.output<typeof S>; // numberz.infer is z.output; use z.input for the pre-transform shape (matters for form libraries).
Validate email, URL, UUID (Zod 4 style)string-formats
const Contact = z.object({
email: z.email(),
site: z.url(),
id: z.uuid(),
});In Zod 4 these are top-level functions; the old z.string().email() chain is deprecated.
Coerce env vars and query stringscoercion
const Env = z.object({
PORT: z.coerce.number().int().default(3000),
DEBUG: z.stringbool().default(false),
});
const env = Env.parse(process.env);z.coerce.boolean() treats any non-empty string (including "false") as true; use z.stringbool() for real "true"/"false" parsing.
Customize error messagescustom-errors
const Password = z
.string({ error: "Password is required" })
.min(8, { error: "At least 8 characters" });Zod 4 unified message, invalid_type_error and errorMap into a single error param; the old names still work but log deprecation in types.
Cross-field validation with refinerefine
const Signup = z
.object({ password: z.string(), confirm: z.string() })
.refine((d) => d.password === d.confirm, {
error: "Passwords do not match",
path: ["confirm"],
});Set path so the issue lands on the right field; async refinements force you onto parseAsync/safeParseAsync.
Parse one of several shapes by a tag fielddiscriminated-union
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
]);Much faster and much better error messages than z.union when the variants share a literal tag.
Recursive types (categories with children)recursive-schema
const Category = z.object({
name: z.string(),
get children() {
return z.array(Category);
},
});
type Category = z.infer<typeof Category>;Zod 4 supports recursion via getters with full inference; the old z.lazy plus manual type annotation dance is no longer needed.
Convert a schema to JSON Schemajson-schema
const User = z.object({ name: z.string(), age: z.number() });
const jsonSchema = z.toJSONSchema(User);
// feed to OpenAPI, LLM structured output, form generatorsBuilt into Zod 4, no zod-to-json-schema package needed; transforms and custom refinements cannot be represented and throw unless you set io or override options.
Shape errors for a form UIflatten-errors
const result = Signup.safeParse(formData);
if (!result.success) {
const tree = z.treeifyError(result.error);
// tree.properties?.password?.errors -> string[]
}error.flatten() and error.format() are deprecated in v4; z.treeifyError and z.flattenError are the replacements.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| valibot | npm | Client-side validation where bundle size matters; modular design tree-shakes to about 1 KB for simple schemas |
| arktype | npm | You want schemas written as TypeScript-like string syntax and faster runtime validation |
| yup | npm | A legacy Formik codebase already uses it; do not pick it fresh, its TS inference is weaker |