zod review
Zod 4.4.3 defines runtime schemas in TypeScript and derives static input and output types from them. A schema parses unknown data into a cloned value or returns structured issues with paths and error codes. Version 4 includes objects, unions, coercion, recursive schemas, refinements, transforms, bidirectional codecs, metadata registries, and JSON Schema conversion. Release 4.4.3 restores `catch` and `preprocess` behavior for absent object properties after regressions in earlier 4.4 patches. Zod checks only the values passed to a parse method; it does not validate every network response or replace ordinary TypeScript types inside trusted code.
Zod 4.4.3 installed in 0.7 seconds with 0 dependencies and 0 audit findings, but our full import still measured 63.2 KB gzipped. It fits TypeScript boundaries that need runtime checks and inferred types; set unknown-key and transform semantics explicitly, and measure real client chunks before making it the browser default.
We installed it
| Install | ✓ · 0.7s | 6 packages on disk · 7 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 63.2 KB | gzipped (323.3 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does zod install cleanly?
Yes. In a fresh container with an empty cache, npm install zod finished in 0.7s, leaving 6 packages and 7 MB on disk. npm audit reported no known vulnerabilities.
How much does zod add to a browser bundle?
63.2 KB gzipped (323.3 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does zod work with both ESM and CommonJS?
Yes. Both import 'zod' and require('zod') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does zod include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
zod or valibot: which should you use?
valibot: Use it for modular browser validation where retained code size is the first constraint. Zod 4.4.3 installed in 0.7 seconds with 0 dependencies and 0 audit findings, but our full import still measured 63.2 KB gzipped.
When should you not use zod?
Every value is created inside trusted, typed code and no runtime boundary exists. Plain TypeScript types add no parser or bundle code.
Use it if
- Unknown JSON, form fields, environment values, webhooks, or model output must become a checked TypeScript value.
- One definition should provide runtime validation, inferred input and output types, and a JSON Schema where semantics permit.
- A router, form resolver, RPC layer, OpenAPI bridge, or structured-output SDK already accepts Zod schemas.
- Readable nested issues and custom business rules matter more than the smallest possible client validator.
- Every value is created inside trusted, typed code and no runtime boundary exists. Plain TypeScript types add no parser or bundle code.
- JSON Schema is the canonical contract or several languages consume it. Ajv compiles JSON Schema directly and avoids translating Zod-only behavior.
- A browser chunk has a tight budget and only simple checks are needed. Our full-package import reached 63.2 KB gzipped; Valibot or Zod Mini may retain less code for a small schema.
- Callers assume a transform has one TypeScript type. Forms often supply `z.input`, while validated application code receives `z.output`.
- Unknown object properties must be preserved or rejected but nobody will set that policy. `z.object` strips them by default; `z.looseObject`, `z.strictObject`, and catchalls behave differently.
Setup reality
We installed Zod 4.4.3 in 0.7 seconds in a fresh Node 22 container. The result was 6 packages and 7 MB on disk. Zod itself declares 0 direct dependencies and 0 peers, is 6,524 KB unpacked, includes TypeScript declarations, and uses the MIT license. npm audit reported 0 known vulnerabilities. The package is ESM with an exports map, and both require() and ESM import worked.
Our full-package esbuild probe measured 323.3 KB minified and 63.2 KB gzipped. That test imported the entire namespace, so it is an upper-bound integration result rather than the cost of one object schema. Named imports can tree-shake, and Zod Mini provides a smaller functional API. Measure the actual client chunk because locales, error helpers, schema composition, and namespace imports change what the bundler retains.
Use TypeScript strict mode and keep one Zod major across adapters. The move from v3 to v4 changed string-format helpers, error customization, records, defaults, and several advanced schemas. An integration typed only for v3 may require a compatibility subpath or duplicate versions. Release 4.4.3 fixes missing-property behavior for catch and preprocess, so tests should include absent keys alongside null, malformed, and valid values.
Set an object policy at each external boundary. z.object strips unknown keys, z.strictObject rejects them, and z.looseObject retains them. parse() throws, while safeParse() returns a discriminated result. Async refinements require parseAsync() or safeParseAsync() at the caller. Transforms and codecs have distinct input and output types, and some refinements cannot be expressed in generated JSON Schema. Build reusable schemas once instead of reconstructing them inside hot request loops.
Patterns
Validate an unknown object parse-object
import * as z from 'zod';
const User = z.object({
id: z.uuid(),
name: z.string().min(1),
age: z.number().int().nonnegative(),
});
const user = User.parse(input);`parse()` throws `ZodError`, returns a clone, and strips unrecognized object keys under plain `z.object`.
Handle invalid input without an exception safe-parse
const result = User.safeParse(input);
if (!result.success) {
return { status: 400, issues: result.error.issues };
}
const user = result.data;The `success` field narrows the result; do not echo user-supplied values from issue data without review.
Separate types around a transform infer-input-output
const Port = z.string()
.transform((value) => Number.parseInt(value, 10))
.pipe(z.number().int().min(1).max(65535));
type PortInput = z.input<typeof Port>;
type PortOutput = z.output<typeof Port>;`z.infer` equals the output type; adapters and forms commonly operate on the pre-transform input type.
Reject unknown object properties reject-extra-keys
const Payload = z.strictObject({
action: z.literal('create'),
name: z.string(),
});Use `z.looseObject` to retain extra keys or `z.object` to strip them; make this choice visible at external boundaries.
Parse environment strings parse-environment
const Env = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DEBUG: z.stringbool().default(false),
});
const env = Env.parse(process.env);`z.coerce.boolean` treats any nonempty string as true; `z.stringbool` understands textual true and false values.
Report a cross-field error on one path validate-related-fields
const Signup = z.object({
password: z.string().min(12),
confirmation: z.string(),
}).refine((data) => data.password === data.confirmation, {
path: ['confirmation'],
error: 'Passwords do not match',
});An async refinement requires `parseAsync()` or `safeParseAsync()`; synchronous parsing cannot run it.
Dispatch a union by a literal field parse-tagged-union
const Event = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('key'), key: z.string() }),
]);Each member must expose a compatible literal discriminator; tagged dispatch usually gives clearer failures than an untagged union.
Validate a recursive tree define-recursive-schema
const Category = z.object({
name: z.string(),
get children() {
return z.array(Category);
},
});
type Category = z.infer<typeof Category>;A schema does not impose a depth limit; cap hostile recursive input before it can exhaust stack or memory.
Flatten issues for a form format-field-errors
const result = Signup.safeParse(values);
if (!result.success) {
const errors = z.flattenError(result.error);
console.log(errors.fieldErrors.confirmation);
}Flattening is convenient for shallow forms; retain original issue paths for nested arrays and objects.
Convert a schema to JSON Schema emit-json-schema
const schema = z.toJSONSchema(User, {
target: 'draft-2020-12',
});Custom refinements and transforms may have no JSON Schema equivalent, so test the generated contract downstream.
Decode and encode an ISO date define-codec
const IsoDate = z.codec(
z.iso.datetime(),
z.date(),
{
decode: (value) => new Date(value),
encode: (date) => date.toISOString(),
},
);
const date = z.decode(IsoDate, input);
const text = z.encode(IsoDate, date);A codec has different input and output types and supports both directions; a transform is one-way.
Return a fallback after validation fails supply-fallback
const DisplayName = z.string().min(1).catch('Anonymous');
const name = DisplayName.parse(input);`catch()` converts bad input into success and can hide upstream data errors; 4.4.3 restores its absent-key handling.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| valibot | npm | Use it for modular browser validation where retained code size is the first constraint. |
| ajv | npm | Use it when JSON Schema is authoritative, shared across languages, or compiled validation throughput matters. |
| arktype | npm | Use it when ArkType's TypeScript-like syntax and type model are a better fit for the team. |
| yup | npm | Use it mainly in an existing form stack already designed around Yup casting and schema mutation. |
More utils guides
lru-cache · type-fest · ajv · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

