How to use Zod 4 for TypeScript validation
By Flavio Copes
Learn Zod 4 schema validation with TypeScript using parse, safeParse, inferred types, records, preprocessing, coercion, and practical API examples.
Zod validates unknown data at runtime and gives you the matching TypeScript type.
You define one schema, parse the input, and use the validated result without maintaining a separate interface.
This guide uses Zod 4.
Install Zod
Install the package:
npm install zod
Then import z:
import { z } from 'zod'
Create a Zod schema
Use z.object() to describe an object:
const User = z.object({
id: z.uuid(),
name: z.string().min(1),
email: z.email(),
age: z.number().int().nonnegative().optional()
})
This is both a runtime validator and a source for a TypeScript type:
type User = z.infer<typeof User>
The inferred type is:
type User = {
id: string
name: string
email: string
age?: number
}
Validate data with parse()
Pass unknown data to parse():
const user = User.parse({
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Flavio',
email: '[email protected]'
})
If the value is valid, parse() returns typed data. If it is invalid, Zod throws a ZodError.
This is useful when invalid data should stop the current operation:
const data: unknown = await response.json()
const user = User.parse(data)
TypeScript alone cannot verify a value returned by an API. Zod performs that check while the program runs.
Handle validation without exceptions
Use safeParse() when validation failure is an expected result:
const result = User.safeParse(data)
if (!result.success) {
console.log(result.error.issues)
return
}
console.log(result.data.email)
The result is a discriminated union. Checking result.success gives you either the error or the typed data.
Each item in error.issues includes a message and the path to the invalid field:
for (const issue of result.error.issues) {
console.log(issue.path.join('.'), issue.message)
}
Use safeParseAsync() when the schema contains an asynchronous refinement or transform.
Common Zod schemas
Zod provides schemas for JavaScript primitives:
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.unknown()
It also provides common string formats:
z.email()
z.url()
z.uuid()
z.iso.date()
z.iso.datetime()
Add constraints by chaining methods:
const Username = z.string()
.min(3)
.max(20)
.regex(/^[a-z0-9_]+$/)
const Price = z.number()
.positive()
.multipleOf(0.01)
Optional, nullable, and default values
These three cases are different:
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
Use default() when an omitted value should receive a fallback:
const Settings = z.object({
theme: z.enum(['light', 'dark']).default('light'),
pageSize: z.number().int().positive().default(20)
})
Arrays, tuples, enums, and unions
Pass the item schema to z.array():
const Tags = z.array(z.string()).min(1)
A tuple validates a fixed sequence of different types:
const Point = z.tuple([z.number(), z.number()])
Use an enum for a fixed set of strings:
const Status = z.enum(['draft', 'published', 'archived'])
Use a union when more than one schema is valid:
const Id = z.union([z.string(), z.number()])
For objects with a shared discriminator, use z.discriminatedUnion():
const Result = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.string() }),
z.object({ status: z.literal('error'), message: z.string() })
])
Create record schemas
Use z.record() for objects with dynamic keys. In Zod 4, pass both the key and value schemas:
const Scores = z.record(z.string(), z.number())
Scores.parse({
alice: 95,
bob: 87
})
You can validate the keys too:
const RolesByUser = z.record(
z.uuid(),
z.enum(['admin', 'user', 'guest'])
)
If the key schema is an enum, z.record() requires every enum key. Use z.partialRecord() when only some keys may be present:
const FeatureFlags = z.partialRecord(
z.enum(['search', 'billing', 'reports']),
z.boolean()
)
Reuse object schemas
Object schemas include helpers similar to TypeScript utility types:
const PublicUser = User.pick({
id: true,
name: true
})
const UpdateUser = User
.omit({ id: true })
.partial()
pick() keeps selected fields. omit() removes fields. partial() makes every field optional.
By default, z.object() removes unrecognized keys from the parsed result. Use z.strictObject() if unknown keys should cause an error:
const Config = z.strictObject({
port: z.number().int(),
host: z.string()
})
Preprocess input before validation
z.preprocess() changes the raw input before another schema validates it:
const TrimmedName = z.preprocess(
value => typeof value === 'string' ? value.trim() : value,
z.string().min(1)
)
This accepts a string with extra whitespace, trims it, then checks that the result is not empty.
For common conversions, use z.coerce:
const Port = z.coerce.number().int().min(1).max(65535)
Port.parse('3000') // 3000
Be careful with boolean coercion. z.coerce.boolean() uses JavaScript’s Boolean() function, so the non-empty string 'false' becomes true.
When parsing the strings 'true' and 'false', define the conversion explicitly:
const BooleanFromString = z
.enum(['true', 'false'])
.transform(value => value === 'true')
Add custom validation
Use refine() for a rule that depends on your own logic:
const Password = z.string().refine(
value => /[A-Z]/.test(value) && /[0-9]/.test(value),
{ error: 'Use at least one uppercase letter and one number' }
)
For a rule involving multiple fields, refine the object and set the error path:
const Signup = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{
error: 'Passwords must match',
path: ['confirmPassword']
}
)
Validate an API response
Here is the complete pattern I use for external JSON:
const Post = z.object({
id: z.number().int(),
title: z.string(),
published: z.boolean()
})
type Post = z.infer<typeof Post>
async function getPost(id: number): Promise<Post> {
const response = await fetch(`/api/posts/${id}`)
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
const data: unknown = await response.json()
return Post.parse(data)
}
The unknown type is intentional. The network response has not earned a trusted type until the schema validates it.
The official Zod documentation contains the complete schema API and the Zod 4 migration notes.