Zod for Runtime Validation in TypeScript: Schemas, Inferred Types, Transforms and Integrations
Key takeaways
TypeScript types vanish at runtime, so API responses and form input still need checking. With Zod one schema does both jobs. The post covers primitives, objects, unions, transforms, optional and default values, recursive schemas and error handling, then compares Zod with Yup and Joi.
What is Zod?
Zod is a TypeScript-first schema validation library. You describe the shape of data once as a schema, and Zod gives you two things from it: a runtime check (parse) and a static type (z.infer). It has no dependencies and runs in browsers, Node.js, Deno and edge runtimes.
The reason a library like this is needed at all is that TypeScript types are erased at compile time. const user = await res.json() as User compiles, but nothing checks that the server actually sent a User; if a field is missing or has the wrong type, the error surfaces far away, as Cannot read properties of undefined in some component. Every value that crosses a boundary (HTTP responses and requests, form input, process.env, localStorage, messages from a queue) is really unknown until something checks it. Writing that check by hand duplicates the type, and the two drift apart. A schema that is the type removes the duplication.
The examples use the Zod 3 API with TypeScript 5.x. Zod 4, released in 2025, keeps most of this API but moves string formats to top-level functions (z.email() instead of z.string().email()) and changes error customization; the differences are noted where they matter.
Installation
npm install zod
Basic Usage
import { z } from 'zod';
// Define schema
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// Infer type
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string; }
// Validate (throws on error)
const user = UserSchema.parse({
name: 'Alice',
age: 30,
email: '[email protected]',
});
// Safe validation (returns result)
const result = UserSchema.safeParse({
name: 'Bob',
age: 'invalid', // Type error
email: '[email protected]',
});
if (!result.success) {
console.log(result.error.issues);
} else {
console.log(result.data);
}
parse returns the data typed as User or throws a ZodError. safeParse never throws; it returns a discriminated union, { success: true, data } or { success: false, error }, and TypeScript narrows result.data only inside the success branch. Two details are easy to miss. First, the returned object is a new object, not the input: unknown keys are stripped by default, so parse({ name, age, email, isAdmin: true }) returns an object without isAdmin. Use .strict() to reject unknown keys or .passthrough() to keep them. Second, the inferred type describes the output after transforms and defaults; z.input<typeof Schema> gives the input type, which differs as soon as you use .default() or .transform().
In practice, parse suits places where invalid data should abort the whole operation (loading config at startup, a queue consumer that sends failures to a dead letter queue), and safeParse suits request handlers and forms, where invalid input is an expected outcome that should turn into a 400 response or a message next to a field, not an exception.
Primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
Note that z.number() rejects numeric strings and NaN; form fields and query parameters are always strings, which is the most common reason a schema “fails for no reason”. Use z.coerce.number() or a preprocess step (below) for such inputs. z.any() and z.unknown() both accept everything, but unknown keeps the inferred type safe, forcing you to narrow before use.
String Validation
z.string()
.min(3)
.max(100)
.email()
.url()
.uuid()
.regex(/^[A-Z]+$/)
.trim()
.toLowerCase()
.toUpperCase()
.datetime() // ISO 8601
.ip() // IPv4 or IPv6
This chain is a catalog of available methods, not a realistic schema; no string is simultaneously an email, a URL and a UUID. Checks run in order, and .trim(), .toLowerCase() and .toUpperCase() are transforms that change the value, so place .trim() before .min(3) if " a " should be rejected. By default, .datetime() accepts only UTC times with a Z suffix; pass { offset: true } to allow +09:00-style offsets. In Zod 4, most of these formats are top-level functions (z.email(), z.uuid(), z.iso.datetime()), and .ip() is split into z.ipv4() and z.ipv6().
Number Validation
z.number()
.min(0)
.max(100)
.int()
.positive()
.negative()
.nonnegative()
.nonpositive()
.multipleOf(5)
.finite()
.safe()
Object Schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.date().optional(),
});
// Partial (all fields optional)
const PartialUser = UserSchema.partial();
// Pick
const UserIdName = UserSchema.pick({ id: true, name: true });
// Omit
const UserWithoutId = UserSchema.omit({ id: true });
// Extend
const ExtendedUser = UserSchema.extend({
phoneNumber: z.string(),
});
// Merge
const MergedSchema = UserSchema.merge(AnotherSchema);
These helpers mirror TypeScript’s Partial, Pick and Omit, and they are the main tool for keeping schemas DRY: define the full entity once, then derive a create schema with .omit({ id: true }) and an update schema with .partial(). Each call returns a new schema; the original is never mutated. .merge() is deprecated in Zod 4 in favor of .extend() or spreading .shape, and when keys overlap, the second schema wins. Refinements added with .refine() belong to the object they were attached to; calling .pick() or .omit() on a refined schema is not allowed in Zod 3 (the refined result is a ZodEffects, not a ZodObject), so derive variants from the unrefined base and refine each one separately.
Arrays and Tuples
// Array
const TagsSchema = z.array(z.string());
const tags = TagsSchema.parse(['typescript', 'zod']);
// Non-empty array
const NonEmptyTags = z.array(z.string()).nonempty();
// Min/Max length
const LimitedTags = z.array(z.string()).min(1).max(5);
// Tuple
const CoordinateSchema = z.tuple([z.number(), z.number()]);
const coord = CoordinateSchema.parse([10, 20]);
// Tuple with rest
const MixedTuple = z.tuple([z.string(), z.number()]).rest(z.boolean());
Unions and Enums
// Union
const StringOrNumber = z.union([z.string(), z.number()]);
// Discriminated union (recommended)
const ResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.any() }),
z.object({ status: z.literal('error'), error: z.string() }),
]);
// Enum
const RoleSchema = z.enum(['admin', 'user', 'guest']);
type Role = z.infer<typeof RoleSchema>; // 'admin' | 'user' | 'guest'
// Native enum
enum NativeRole {
Admin = 'ADMIN',
User = 'USER',
}
const NativeRoleSchema = z.nativeEnum(NativeRole);
A plain z.union tries each option in order and, if all fail, reports the errors from every branch, which produces long, unhelpful error messages for object unions. z.discriminatedUnion reads the discriminator key first (status here) and validates only the matching branch, so errors point at the fields that are actually wrong, and the inferred type narrows cleanly with if (res.status === 'success'). Use it whenever objects share a literal tag. The data: z.any() in the example is a placeholder; in real code, give the success branch a concrete schema, otherwise the inferred data is any and the type safety ends right there.
z.enum takes string literals and gives you RoleSchema.options and RoleSchema.enum.admin at runtime. z.nativeEnum exists for existing TypeScript enum declarations; in Zod 4 both are handled by z.enum.
Transform and Preprocess
Transform
const DateSchema = z.string().transform((str) => new Date(str));
const date = DateSchema.parse('2024-01-01'); // Date object
// With validation
const PositiveNumber = z.number()
.transform((val) => Math.abs(val))
.pipe(z.number().positive());
transform runs after the input has passed validation and can return any type; the inferred output type follows. The DateSchema above is a trap, though: new Date('not a date') produces an Invalid Date object without throwing, so the schema accepts garbage. Validate the string first (z.string().datetime().transform(...)) or pipe the result into z.date(), which rejects invalid dates. .pipe() feeds the output of one schema into another and is the clean way to validate after a transform; in the PositiveNumber example, 0 still fails because Math.abs(0) is not positive.
Preprocess
const CoerceNumber = z.preprocess(
(val) => Number(val),
z.number()
);
const num = CoerceNumber.parse('42'); // 42 (number)
preprocess runs before validation, on the raw input. z.coerce.number() is shorthand for the same idea. Both inherit JavaScript’s conversion rules, which are the source of subtle bugs: Number('') is 0, so an empty form field silently becomes zero instead of failing a required check, and Number(undefined) is NaN, which z.number() rejects with “Expected number, received nan”. Likewise z.coerce.boolean() uses Boolean(value), so the string "false" becomes true. For query strings and environment variables, an explicit preprocess that maps '' to undefined or compares against 'true' is safer than blind coercion.
Optional, Nullable, Default
// Optional (T | undefined)
const OptionalString = z.string().optional();
// Nullable (T | null)
const NullableString = z.string().nullable();
// Both (T | null | undefined)
const NullishString = z.string().nullish();
// Default value
const WithDefault = z.string().default('default value');
// Catch (fallback on error)
const WithCatch = z.string().catch('fallback');
The distinction between optional and nullable matters when you talk to APIs and databases: JSON can carry null but not undefined, and many backends send null for missing values, so a schema with .optional() rejects { nickname: null }. .default() applies only when the value is undefined, never for null or an empty string. .catch() is different in kind: it swallows any validation error and substitutes the fallback, which is useful for tolerant parsing of things like a saved UI preference, and dangerous for anything where silently replacing bad data would hide a bug.
React Hook Form Integration
npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginForm = z.infer<typeof LoginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
});
const onSubmit = (data: LoginForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
zodResolver runs the schema whenever React Hook Form validates (on submit by default, configurable with mode) and maps each Zod issue’s path to the matching field in errors, so the custom messages in the schema appear next to the right input. onSubmit is only called with data that passed the schema. HTML inputs always produce strings, so a numeric field needs either register('age', { valueAsNumber: true }) or z.coerce.number() in the schema; forgetting this is the usual reason a number field shows “Expected number, received string”. Cross-field rules such as “passwords must match” go in .refine() on the object with path: ['confirmPassword'], so the error attaches to a field rather than to the form root.
tRPC Integration
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
createUser: t.procedure
.input(
z.object({
name: z.string(),
email: z.string().email(),
})
)
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
});
tRPC calls the .input() schema on every request before your resolver runs. If validation fails, the client receives a BAD_REQUEST error with the Zod issues attached, and input inside the resolver is already typed from the schema, so the client and the server share the type without any code generation. Remember that this is a network boundary: even though the TypeScript client cannot send the wrong shape, anyone can call the endpoint with curl, which is precisely why the runtime check is there. Passing input straight into db.user.create is safe only because the schema strips unknown keys; with .passthrough() a caller could inject extra columns such as role.
Error Handling
Custom Error Messages
const UserSchema = z.object({
email: z.string().email('Please provide a valid email address'),
age: z.number().min(18, 'Must be at least 18 years old'),
});
Error Map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'This field must be text' };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
Format Errors
const result = UserSchema.safeParse(data);
if (!result.success) {
const formatted = result.error.format();
console.log(formatted.email?._errors);
console.log(formatted.age?._errors);
}
Each issue in error.issues has a code, a message and a path array (['address', 'zip'] for nested fields), which is enough to build any error format you need. format() returns a nested object mirroring the schema, and flatten() returns { formErrors, fieldErrors }, which is simpler for flat forms. A global setErrorMap affects every schema in the process, including those in libraries you import, so localizing messages this way can have surprising reach. In Zod 4, error maps are replaced by an error parameter and z.config(), and format()/flatten() give way to z.treeifyError() and z.flattenError().
When logging validation failures, log the issues and not the raw input, or sanitize it first; request bodies routinely contain passwords and tokens, and “log the whole payload on validation error” is a common way secrets end up in log storage.
Recursive Schemas
interface Category {
name: string;
subcategories: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);
This is the one place where inference does not work: TypeScript cannot infer a type that refers to itself, so you write the interface by hand and annotate the schema with z.ZodType<Category>. z.lazy defers evaluating the inner schema until parse time, which breaks the “used before defined” cycle. If the schema has transforms, the input and output types differ and the annotation becomes z.ZodType<Output, z.ZodTypeDef, Input>. Zod validates recursive data by recursion, so extremely deep untrusted input can exhaust the stack; if you accept trees from the outside, also limit depth.
Performance: Lazy Validation
// Expensive schema
const ExpensiveSchema = z.lazy(() =>
z.object({
// Heavy computation
})
);
// Only validates when called
const result = ExpensiveSchema.parse(data);
z.lazy does not make validation cheaper. It only postpones building the schema until the first parse, which helps with recursive definitions and circular imports between schema files. The real performance rules are simpler: define schemas once at module scope rather than inside a render function or request handler (building a schema allocates objects each time), and validate at boundaries rather than re-validating the same data deep inside the program. Zod 3 is noticeably slower than hand-written checks for very large arrays; if profiling shows validation as a hotspot, Zod 4 or a compiled validator is worth considering.
Comparison: Zod vs Yup vs Joi
| Feature | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript | Written in TS, designed for inference | Written in TS since v1 | Ships type declarations |
| Type Inference | z.infer | InferType | None (write types by hand) |
| Bundle Size | Small (smaller still in Zod 4 Mini) | Small | Large; mainly used server-side |
| Browser Support | Yes | Yes | Yes, but rarely used there |
| Async Validation | Yes (parseAsync) | Yes | Yes |
| Transform | Yes | Yes | Yes |
| tRPC Support | Yes | Yes | Via custom parser |
Bundle sizes change between versions, so check a tool like bundlephobia for the versions you would actually ship rather than relying on numbers in articles.
When to use:
- Zod: New TypeScript projects, shared front-end/back-end schemas, tRPC, React Hook Form
- Yup: Existing Formik code where a migration buys little
- Joi: Existing hapi or Node.js services built around it
Newer alternatives are worth knowing about too: Valibot uses a function-per-check design that tree-shakes to very small bundles, and ArkType parses TypeScript-like string syntax. Libraries implementing the Standard Schema interface can be swapped in tools that support it, which reduces lock-in.
Production Best Practices
API Input Validation
// Express example
app.post('/api/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues,
});
}
const user = await createUser(result.data);
res.json(user);
});
Returning result.error.issues gives clients machine-readable paths and codes. Returning error.message instead exposes a JSON string of the issues, which is less useful. If many routes do this, a small middleware such as validate(schema) that parses req.body and attaches the typed result keeps handlers short. Also validate req.params and req.query: they are strings, so IDs need z.coerce.number().int().positive().
Environment Variables
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
PORT: z.string().transform(Number).pipe(z.number().positive()),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
const env = EnvSchema.parse(process.env);
export default env;
This is one of the highest-value uses of Zod, and one I add to almost every service: parsing process.env once at startup turns “the app crashed an hour later because PORT was undefined” into an immediate, readable failure listing every missing or malformed variable. Since everything in process.env is a string or undefined, numbers and booleans must be converted, as PORT is here. Import env everywhere instead of reading process.env directly, so the typed, validated object is the only source. In front-end bundlers, remember that only variables exposed at build time (VITE_, NEXT_PUBLIC_) exist in the browser, and anything in that schema ends up in the shipped bundle.
Reusable Schemas
// schemas/user.ts
export const UserIdSchema = z.number().positive();
export const EmailSchema = z.string().email().toLowerCase();
export const UserNameSchema = z.string().min(2).max(50);
export const CreateUserSchema = z.object({
email: EmailSchema,
name: UserNameSchema,
});
export const UpdateUserSchema = CreateUserSchema.partial();
Related Articles
- tRPC: End-to-End Type Safety Without Codegen, and Where the Types Stop Protecting You
- React Hook Form Guide
- Prisma ORM in TypeScript
Frequently Asked Questions (FAQ)
Q. Can I use Zod without TypeScript?
A. Yes, but you lose the main benefit (type inference). Zod works in plain JavaScript for runtime validation only.
Q. How to validate file uploads?
A. Use z.instanceof(File) for browser File objects or custom validation with .refine() for size/type checks.
Q. Does Zod support async validation?
A. Yes, use .refine() or .superRefine() with async functions. However, prefer synchronous validation when possible for performance.
Q. How to migrate from Yup to Zod?
A. Replace Yup schemas with equivalent Zod syntax. Most concepts map directly. Use z.infer instead of InferType. Update error handling from ValidationError to Zod’s error format.
Q. Can I use Zod for database schemas?
A. Zod validates runtime data. For database schemas, use Prisma or TypeORM. However, Zod works great for validating API inputs before database operations.
Q. How to handle nested validation errors?
A. Use result.error.flatten() or result.error.format() to structure nested errors for UI display.
Production Operations
Monitoring Validation Failures
const result = UserSchema.safeParse(data);
if (!result.success) {
logger.warn('Validation failed', {
schema: 'UserSchema',
errors: result.error.issues,
input: sanitize(data),
});
}
Performance Considerations
- Define schemas at module scope, not inside handlers or components
- Parse static config once and reuse the result
- Validate at boundaries (API entry points), then trust the typed data inside
- Use discriminated unions instead of
.or()for object unions: one branch is checked and errors are clearer
Common Pitfalls
| Issue | Cause | Solution |
|---|---|---|
| ”Expected number, received string” | Form, query or env values are strings | z.coerce.number() or valueAsNumber |
| Extra fields disappear | Objects strip unknown keys by default | .passthrough() or .strict() deliberately |
z.date() fails on API data | JSON dates are strings | Validate as a datetime string, then transform |
| Refinement error attached to form root | .refine() on an object without path | Pass { path: ['field'] } |
| Recursive type error | TypeScript cannot infer self-referential types | Explicit interface plus z.lazy() |