Zod: Type-Safe Schema Validation for TypeScript
Everything you need to know about Zod—TypeScript-first validation, type inference, and real-world patterns
TypeScript Masterclass
AVAILABLE NOW with a 50% launch discount!
TypeScript gives you type safety at compile time. But what about runtime? When data comes from an API, user input, or environment variables, you can’t trust it. That’s where Zod comes in.
Zod is a TypeScript-first schema validation library. You define a schema, and Zod validates data against it. The best part? TypeScript automatically infers types from your schemas. One schema, both validation and types.
I used to write separate TypeScript types and validation logic. Types for development, validators for runtime. Keeping them in sync was a nightmare. Then I discovered Zod. Now I define schemas once and get both for free.
When to use Zod
Use Zod when:
- Validating external data (API responses, user input, files)
- Parsing environment variables
- Ensuring type safety at runtime
- You want schemas and types from one source
- Building forms with validation
- Working with databases (validate input/output)
Don’t use Zod when data is already type-safe (internal functions).
Installation
npm install zod
#or
pnpm install zod
#or
bun install zod
That’s it. No peer dependencies, no configuration needed.
Basic usage
Define a schema and parse data:
import { z } from 'zod'
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.email()
})
// Parse data
const user = userSchema.parse({
name: 'Flavio',
age: 42,
email: '[email protected]'
})
// user is typed as { name: string; age: number; email: string }
If validation fails, Zod throws an error:
try {
userSchema.parse({
name: 'Flavio',
age: 'forty-two', // ✗ Wrong type
email: 'not-an-email' // ✗ Invalid format
})
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error)
/*
ZodError: [
{
"expected": "number",
"code": "invalid_type",
"path": [
"age"
],
"message": "Invalid input: expected number, received string"
},
{
"origin": "string",
"code": "invalid_format",
"format": "email",
"pattern": "/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/",
"path": [
"email"
],
"message": "Invalid email address"
}
]
*/
}
}
Safe parsing
Use safeParse
to avoid throwing:
const result = userSchema.safeParse({
name: 'Flavio',
age: 42,
email: '[email protected]'
})
if (result.success) {
console.log(result.data) // Typed data
} else {
console.log(result.error) // ZodError with details
}
This is my preferred approach. It’s more functional and easier to handle errors gracefully.
Error handling
In Zod, handling validation errors is both simple and structured. When using parse
, invalid input will cause a ZodError
to be thrown. However, in most practical situations—such as processing data from APIs or form submissions—it’s safer to use safeParse
, which never throws but instead returns a result object. If validation fails, this object contains detailed error data.
The key to processing validation problems in Zod is the issues
array found on the error object. Each entry in error.issues
represents a specific validation problem: it includes a human-readable message, the failed field’s path, and other useful info. Iterating over this array is the standard way to surface individual errors to users, link them to form fields, or log them for debugging.
Common error handling patterns include:
- Single generic error: Showing a general message to users.
- Field-level errors: Mapping messages from
issues
to form fields. - Detailed logging: Collecting all error details for troubleshooting or API responses.
Here’s a typical example using error.issues
:
const result = userSchema.safeParse(data)
if (!result.success) {
// Iterate through all validation issues
result.error.issues.forEach((issue) => {
console.log(`Validation failed at [${issue.path.join('.')}] - ${issue.message}`)
})
}
Type inference
A “killer feature” of Zod is automatic type inference.
Type inference in Zod lets you automatically generate TypeScript types from your validation schemas.
const userSchema = z.object({
name: z.string(),
age: z.number(),
admin: z.boolean()
})
type User = z.infer<typeof userSchema>
// { name: string; age: number; admin: boolean }
function createUser(data: User) {
// data is fully typed
}
In the example above, we use z.infer<typeof userSchema>
to automatically infer the TypeScript type from the Zod schema. This means that the User
type exactly matches the structure and types defined in userSchema
, including all nested fields and their constraints.
When you pass a value to createUser
, it must match the inferred type, ensuring compile-time safety and eliminating type duplication. If you later update the schema (for example, by adding more fields or requirements), the User
type will update automatically.
This pairing of schemas and types enables you to:
- Keep data validation and type definitions always synchronized
- Avoid manually defining interfaces or types for validated data
- Ensure type safety across function inputs and outputs whenever you use
.infer<typeof schemaName>
This is especially powerful for building APIs, forms, and backend logic where validation and type guarantees are both needed.
Primitive types
Zod supports all JavaScript primitives:
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
String validation
Strings have built-in validators:
z.string().min(3) // Min length
z.string().max(100) // Max length
z.string().length(10) // Exact length
z.string().regex(/^[A-Z]+$/) // Match pattern
z.string().startsWith('https://') // Starts with
z.string().endsWith('.com') // Ends with
z.string().trim() // Trim whitespace
z.string().toLowerCase() // Convert to lowercase
z.string().toUpperCase() // Convert to uppercase
You can chain validators:
const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores')
Number validation
Numbers have their own validators:
z.number().min(0) // Minimum value
z.number().max(100) // Maximum value
z.number().int() // Integer only
z.number().positive() // > 0
z.number().negative() // < 0
z.number().nonnegative() // >= 0
z.number().nonpositive() // <= 0
z.number().multipleOf(5) // Divisible by 5
Other kinds of validations
z.url() // Valid URL
z.uuid() // Valid UUID
z.email() // Valid email
Custom error messages
You can provide custom messages for any validator:
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
age: z.number().min(18, 'You must be at least 18 years old'),
email: z.email('Please enter a valid email address')
})
Optional and nullable
Fields can be optional or nullable:
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
// With default values
z.string().default('guest') // string (never undefined)
z.number().default(0) // number (never undefined)
Objects
The z.object()
function allows you to define schemas for objects with specific shapes and nested validation logic.
Example:
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
country: z.string()
})
const userSchema = z.object({
name: z.string(),
email: z.email(),
address: addressSchema, // Nested object
phone: z.string().optional(), // Optional field
age: z.number().default(0) // Default value
})
Defining object schemas with z.object()
is useful because it allows you to precisely model the shape and rules of complex data structures.
With nested validation, optional and default fields, you can ensure that incoming data matches exactly what your application expects.
This not only reduces bugs from unexpected data shapes, but also provides strong type inference in TypeScript, so your code is safer and easier to maintain.
Additionally, it enables better error reporting and easier form validation, especially in APIs and user input scenarios where robust data checking is essential.
Partial, pick, omit
The partial
, pick
, and omit
methods allow you to transform object schemas in Zod:
- partial(): Makes all properties of an object schema optional.
- pick(): Creates a new schema containing only the specified properties.
- omit(): Creates a new schema by removing the specified properties.
These methods are useful when you want to reuse and adapt existing schemas for different use-cases, such as building input types for PATCH requests, or exposing only certain fields to clients. They help you avoid repeating yourself while ensuring validation and type inference remain accurate.
Example:
const userSchema = z.object({
name: z.string(),
email: z.email(),
age: z.number(),
admin: z.boolean()
})
// All fields optional
const partialSchema = userSchema.partial()
// { name?: string; email?: string; age?: number; admin?: boolean }
// Pick specific fields
const nameEmailSchema = userSchema.pick({ name: true, email: true })
// { name: string; email: string }
// Omit specific fields
const publicSchema = userSchema.omit({ admin: true })
// { name: string; email: string; age: number }
// All fields required
const requiredSchema = userSchema.required()
Arrays
Arrays in Zod are represented using the z.array()
method. You pass a Zod schema that describes the type of elements the array should contain. This allows you to validate arrays of any type, including primitives and objects, and to add length constraints or other validations.
Common use-cases include:
- Enforcing that every item has the correct type
- Defining requirements on array length (e.g., minimum or maximum number of items)
For example:
z.array(z.string()) // array of strings: string[]
z.array(z.number()) // array of numbers: number[]
z.array(z.boolean()) // array of booleans: boolean[]
You can also add constraints:
z.array(z.number()).min(1) // At least one number
z.array(z.string()).max(5) // At most five strings
z.array(z.string()).nonempty() // Must contain at least one string
And you can validate arrays of objects:
const usersSchema = z.array(z.object({
name: z.string(),
age: z.number()
}))
If the array validation fails, Zod will provide detailed error messages for the array itself and for each problematic element.
Tuples
A tuple in Zod is a fixed-length array schema, where each element can have a different type as defined by the order of the schemas in the tuple.
Example:
// [string, number]
const tuple = z.tuple([z.string(), z.number()])
tuple.parse(['Flavio', 42]) // ✓
tuple.parse(['Flavio', 'Bob']) // ✗ Second item must be number
tuple.parse(['Flavio']) // ✗ Must have 2 items
// With rest parameter
const tupleWithRest = z.tuple([z.string(), z.number()]).rest(z.boolean())
// [string, number, ...boolean[]]
The example demonstrates how to use Zod to define tuple schemas in TypeScript.
z.tuple([z.string(), z.number()])
creates a tuple schema that only accepts arrays with exactly two items: the first must be a string and the second a number. Parsing['Flavio', 42]
succeeds, but attempts to parse['Flavio', 'Bob']
(wrong type) or['Flavio']
(too few items) will result in validation errors.- You can extend tuples using
.rest()
to allow additional items of a specific type. For example,z.tuple([z.string(), z.number()]).rest(z.boolean())
describes a tuple with a required string, a required number, and then any number of boolean values after that.
This feature is helpful when you need to strictly control the structure and types of data in ordered arrays.
Enums
Enums in Zod allow you to specify a set of exact string values that a field is allowed to take. This is useful when you want to restrict inputs to a limited set of options, similar to TypeScript’s native string enums or union types.
Here’s how you can define and use an enum schema in Zod, along with type inference:
// Native enum
const statusEnum = z.enum(['draft', 'published', 'archived'])
type Status = z.infer<typeof statusEnum>
// 'draft' | 'published' | 'archived'
statusEnum.parse('draft') // ✓
statusEnum.parse('deleted') // ✗
// Get enum values
statusEnum.options // ['draft', 'published', 'archived']
The example above demonstrates how to define and work with enums in Zod.
z.enum(['draft', 'published', 'archived'])
creates a schema that only allows these exact string values.- The inferred
Status
type will be'draft' | 'published' | 'archived'
, so anywhere you useStatus
, TypeScript will enforce only those valid strings. - Using
statusEnum.parse('draft')
will pass validation, butstatusEnum.parse('deleted')
will throw a validation error since ‘deleted’ is not one of the allowed values. - The
.options
property lets you access the array of allowed values directly.
This approach is helpful for restricting values such as status fields, roles, or other string values with a specific set of allowed options, and ensures both runtime validation and compile-time type safety.
Unions
Unions in Zod allow you to create schemas that accept values matching any one of several specified types or schemas. This is useful when your data can legitimately be of multiple forms—a common pattern in APIs, forms, and domain models.
When you define a union schema with z.union([ ... ])
, Zod will validate the data against each member schema in order; if any member schema matches, validation passes. The inferred TypeScript type will be a union of the types from each member.
Here’s an example demonstrating both simple and discriminated unions in Zod:
const stringOrNumber = z.union([z.string(), z.number()])
stringOrNumber.parse('hello') // ✓
stringOrNumber.parse(42) // ✓
stringOrNumber.parse(true) // ✗
// Discriminated unions
const resultSchema = z.union([
z.object({
status: z.literal('success'),
data: z.string()
}),
z.object({
status: z.literal('error'),
error: z.string()
})
])
type Result = z.infer<typeof resultSchema>
// { status: 'success'; data: string } | { status: 'error'; error: string }
Literals
Literals in Zod are used to specify that a schema should accept exactly one specific value. You create a literal schema using z.literal(value)
, where value
is the exact value you want to accept (string, number, boolean, or even null
).
This is useful when you want to restrict input to a single value, or when constructing more complex schemas such as discriminated unions or constant fields.
Here are some examples:
z.literal('hello') // Only the string 'hello' is valid
z.literal(42) // Only the number 42 is valid
z.literal(true) // Only the boolean true is valid
z.literal(null) // Only null is valid
Literals are often used with unions to allow a fixed set of values:
const statusSchema = z.union([
z.literal('pending'),
z.literal('approved'),
z.literal('rejected')
])
// Only 'pending', 'approved', or 'rejected' are valid
The inferred TypeScript type from z.literal('hello')
will be exactly 'hello'
, not just string
. This means you get strict type checking wherever the schema is used.
Records
Records in Zod are an elegant way to describe objects with arbitrary keys of a specific type and values of another specific type.
You create a record schema using z.record()
. By default, the keys are strings (or optionally, more constrained strings using z.string().regex(...)
or similar), and you specify the value schema.
This is particularly useful when you have objects whose keys are not known ahead of time, but their values must all conform to the same schema. For example, a dictionary of user scores, configuration maps, or dynamic key-value stores.
Examples:
// A record where every value must be a number
z.record(z.number())
// Equivalent to: Record<string, number>
// A record where every value must be a string
z.record(z.string())
// Equivalent to: Record<string, string>
You can also specify a key schema for more control:
// Keys must match a specific pattern (e.g., UUIDs) and values are roles:
z.record(
z.string().uuid(),
z.enum(['admin', 'user', 'guest'])
)
// Equivalent to: Record<UuidString, 'admin' | 'user' | 'guest'>
When parsing, Zod will check both that keys pass the key schema (if specified) and that every value matches the value schema. Records let you express dynamic yet type-safe object structures concisely.
Here’s an example:
// Record<string, number>
const scoresSchema = z.record(z.number())
scoresSchema.parse({
alice: 95,
bob: 87,
charlie: 92
})
// Record with specific key type
const userRolesSchema = z.record(
z.string().uuid(), // Keys must be UUIDs
z.enum(['admin', 'user', 'guest'])
)
In the example above, we show how to use z.record()
to create schemas for objects with dynamic keys and consistent value types.
z.record(z.number())
describes an object where every property value must be a number, and the keys are arbitrary strings. This is great for validating things like scoreboards or data maps (Record<string, number>
).- You can make all values strings with
z.record(z.string())
. - If you also want to constrain the format of the keys, you can provide a Zod schema for the key as the first argument. For example,
z.record(z.string().uuid(), z.enum(['admin', 'user', 'guest']))
means every key must be a valid UUID string and every value must be one of'admin'
,'user'
, or'guest'
.
When you call .parse()
on these schemas, Zod validates that all keys and/or values match their respective schemas. This lets you ensure type safety even when dealing with objects of unknown shape at compile time, as long as the rules about keys and values are clear.
Maps and Sets
Zod provides built-in support for working with JavaScript’s Map
and Set
objects.
- Maps: Use
z.map(keySchema, valueSchema)
to create schemas forMap
instances where both the keys and values must satisfy specific Zod schemas. This allows you to enforce type and shape constraints on both sides of the Map entries. - Sets: Use
z.set(schema)
to validate aSet
in which every element must pass the provided schema. You can also chain.min(size)
and.max(size)
to require a certain set size.
These schemas ensure that even complex data structures like Maps and Sets are fully validated, making your data contracts comprehensive and type-safe.
Example:
z.map(z.string(), z.number())
// Map<string, number>
z.set(z.string())
// Set<string>
z.set(z.number()).min(1).max(10)
// Set with size constraints
Transformations
You can use Zod to transform data during parsing:
const stringToNumber = z.string().transform(val => parseInt(val, 10))
stringToNumber.parse('42') // Returns 42 (number)
// Email normalization
const emailSchema = z.email()
.transform(val => val.toLowerCase())
emailSchema.parse('[email protected]') // Returns '[email protected]'
// Parse date strings
const dateSchema = z.string().transform(val => new Date(val))
dateSchema.parse('2024-01-01') // Returns Date object
The examples above show how Zod can be used not just to validate data, but also to transform it while parsing.
- In the first example,
z.string().transform(val => parseInt(val, 10))
, any string passed toparse
will be converted to a number usingparseInt
. So'42'
becomes42
. - The second example demonstrates normalizing emails: the input (which must be an email) will be converted to lowercase—helpful for case-insensitive comparisons.
- The third example parses a date string and transforms it into a JavaScript
Date
object, so usingdateSchema.parse('2024-01-01')
returns a validDate
instance.
By chaining .transform(callback)
, you can enforce validation and perform conversion in one step, making your schema logic concise and expressive.
Refinements
The .refine()
method in Zod lets you add custom validation logic that isn’t covered by the built-in validators. You pass a function that receives the value being parsed; if it returns false
, Zod adds a validation error using the optional custom message you provide.
Example:
const passwordSchema = z.string().refine(
val => val.length >= 8,
{ message: 'Password must be at least 8 characters' }
)
// Multiple conditions
const strongPasswordSchema = z.string()
.refine(val => val.length >= 8, 'Min 8 characters')
.refine(val => /[A-Z]/.test(val), 'Must contain uppercase')
.refine(val => /[0-9]/.test(val), 'Must contain number')
In the example above:
z.string().refine(val => val.length >= 8, { message: 'Password must be at least 8 characters' })
ensures the string is at least 8 characters long, with a custom error message if it isn’t.- You can chain multiple
.refine()
calls to layer additional custom rules, for example, enforcing the presence of an uppercase letter and a number as shown in thestrongPasswordSchema
.
This feature is very powerful for scenarios where your validation requirements are domain-specific and can’t be covered by Zod’s built-in methods. The optional message lets you report clear, user-friendly error details.
It’s especially useful for passwords, username checks, or any scenario where the logic is not just a simple type or pattern match.
Async validation
Zod can perform async validation, especially useful for server-side checks:
const usernameSchema = z.string().refine(
async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const data = await response.json()
return data.available
},
{ message: 'Username is taken' }
)
// Use with parseAsync
const result = await usernameSchema.parseAsync('Flavio')
The async validation example shows how you can use Zod’s .refine()
method to perform checks that require asynchronous logic, such as contacting a backend API. In the code:
- A
usernameSchema
is defined as a string with a custom async.refine()
check: it makes a network request to/api/check-username
, sending the chosen username as a query parameter. The server responds with JSON indicating if the username is available. - If the username is taken (
data.available
is false), validation fails and Zod attaches the custom error message ‘Username is taken’. - Since the refinement is asynchronous, you must use
parseAsync
instead ofparse
to validate input and await the result.
This pattern is ideal for cases like checking username uniqueness, verifying invite codes, or validating data dependent on external resources. It allows you to keep both synchronous and asynchronous validation logic together, with all errors collected in a consistent way.
Preprocessing
Preprocessing is useful when the raw input you receive isn’t in the exact form your schema expects. For example, you might get strings with extra spaces, numbers sent as strings, or form values that need normalization before validation. With z.preprocess
, you can transform the input (trim, convert, clean up, or adjust values) before Zod runs the type checks or other validators.
This greatly improves data flexibility and user experience. For instance:
- Form fields often produce strings, even if the intended type is a number or boolean. Preprocessing lets you convert those before validation fails.
- When validating user-submitted data, you might want to trim whitespace or set default values in a preprocessing step.
- If your API or external data source doesn’t always strictly match your types, preprocessing helps “massage” the data into the right shape.
Overall, preprocessing reduces rejected inputs due to innocuous formatting differences and allows you to centrally manage input normalization alongside your validation schema.
Here’s an example:
const trimmedString = z.preprocess(
(val) => typeof val === 'string' ? val.trim() : val,
z.string().min(1)
)
trimmedString.parse(' hello ') // Returns 'hello'
// Parse numbers from strings
const numberFromString = z.preprocess(
(val) => {
if (typeof val === 'string') {
const num = parseFloat(val)
return isNaN(num) ? val : num
}
return val
},
z.number()
)
numberFromString.parse('42') // Returns 42 (number)
The example demonstrates how to use Zod’s z.preprocess
method to transform input values before they are validated by the schema.
- In the first part, a schema called
trimmedString
is created. The preprocessing function checks if the input is a string and, if so, trims whitespace from both ends. This ensures that users’ input like' hello '
will be converted to'hello'
before the validation runs. The underlying Zod schema then validates that the resulting string is at least one character long (i.e., not empty). - In the second part, a schema called
numberFromString
is defined. Here, the preprocessing function checks if the input is a string. It then tries to parse this string as a floating-point number. If the parsing is successful (the result is notNaN
), the number is passed to the next stage; otherwise, the original value is kept. The base Zod schema then checks that the final value is actually a number.
This approach lets you accept a wider range of input types (like numbers sent as strings from form fields) without rejecting them outright, by automatically converting them into the form your application expects before running validation logic.
Coercion
Coercion in Zod allows you to automatically convert input values to the desired type before validating them. This is especially useful when working with data from user input, forms, environment variables, or APIs, where the values may arrive as strings regardless of the expected type (for example, numbers, booleans, or dates).
By using the z.coerce
namespace, you can specify schemas that will attempt to coerce inputs:
z.coerce.string()
will turn the input into a string.z.coerce.number()
will convert the input to a number (usingNumber()
).z.coerce.boolean()
will coerce string values like"true"
or"false"
into booleans.z.coerce.date()
will attempt to construct a JavaScriptDate
from the input.
This feature reduces friction when validating real-world inputs, since users and external systems often provide data in the “wrong” type, but which can safely be converted. When coercion fails (e.g., parsing an invalid number), Zod will report a validation error.
For example, if a user submits a form where all values are strings, z.coerce.number()
lets you accept and parse "42"
into the number 42
transparently—ensuring type correctness in your validated data.
Examples:
// Examples
z.coerce.number().parse('42') // Returns 42
z.coerce.boolean().parse('true') // Returns true
z.coerce.date().parse('2025-01-01') // Returns Date
Real-world patterns
Let’s look at how Zod is used in real-world scenarios. Here are a few practical patterns for applying Zod to common problems like environment variable validation, API response parsing, and form validation. These patterns demonstrate how Zod keeps your data safe, your types correct, and your codebase robust at runtime.
Environment variables:
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
API_KEY: z.string().min(1)
})
const env = envSchema.parse(process.env)
// Now env is fully typed and validated
env.DATABASE_URL // string
env.PORT // number
In the example above, we use Zod to validate environment variables. The envSchema
defines the expected structure of the environment, with detailed types and constraints:
NODE_ENV
must be one of the specified strings ('development'
,'production'
, or'test'
).DATABASE_URL
must be a valid URL string.PORT
is coerced to a number (accepting strings as input) and defaults to 3000 if not provided.API_KEY
must be a non-empty string (minimum length of 1).
When you call envSchema.parse(process.env)
, Zod checks each variable in process.env
against these rules. If validation passes, you get a fully typed and safe env
object. If any variable is missing or invalid, Zod throws a detailed error, preventing your app from running with misconfigured or missing environment variables. This ensures robust, predictable behavior and eliminates a common source of runtime bugs in Node.js and TypeScript projects.
API responses:
const userApiSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.email(),
created_at: z.string().transform(val => new Date(val)),
settings: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
})
})
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
// Validate API response
return userApiSchema.parse(data)
}
const user = await fetchUser('123')
// user is fully typed with Date object for created_at
The example above demonstrates how to use Zod to validate and type-check data received from an API. Here’s how it works step-by-step:
- First, a Zod schema (
userApiSchema
) describes the exact expected structure of a user API response. It specifies types and constraints for each field, such as requiring theid
to be a UUID string,created_at
to be a string that is transformed into aDate
object, andsettings
to be a nested object with strict options. - The
fetchUser
function fetches user data from an API endpoint. After retrieving and parsing the response JSON, it usesuserApiSchema.parse(data)
to both validate and type the result. - If the data matches the schema, you get a fully typed
user
object that you can safely use in your code, with all fields guaranteed. For example,created_at
will already be a JavaScriptDate
object due to the transformation step. - If the API response is malformed, missing fields, or has incorrect formats, Zod throws a detailed error, making bugs and type mismatches visible immediately.
This approach eliminates a whole class of runtime errors and casts from your application logic, letting your TypeScript types always reflect real, validated data—even when that data comes from untrusted external sources like APIs.
Form validation:
const signupSchema = z.object({
username: z.string()
.min(3, 'Min 3 characters')
.max(20, 'Max 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscore'),
email: z.email('Invalid email'),
password: z.string()
.min(8, 'Min 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
age: z.coerce.number().min(18, 'Must be 18+'),
terms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' })
})
}).refine(
data => data.password === data.confirmPassword,
{
message: 'Passwords must match',
path: ['confirmPassword']
}
)
function handleSubmit(formData: FormData) {
const data = Object.fromEntries(formData)
const result = signupSchema.safeParse(data)
if (!result.success) {
// Show errors
result.error.errors.forEach(err => {
console.log(`${err.path}: ${err.message}`)
})
return
}
// result.data is fully typed and validated
submitSignup(result.data)
}
The above example demonstrates how to use Zod for robust form validation in TypeScript, especially for user sign-ups.
Here’s a breakdown of what the code does:
-
Schema definition:
AsignupSchema
is created usingz.object()
, specifying the requirements for each field:username
: Must be 3-20 characters and only contain letters, numbers, or underscores.email
: Must be a valid email address.password
: At least 8 characters, with an uppercase letter and a number.confirmPassword
: Must be present (checked for equality later).age
: Coerced to a number and must be at least 18.terms
: Must betrue
(i.e., the user has accepted the terms).
-
Custom validation:
.refine()
checks thatpassword
andconfirmPassword
match, displaying an error onconfirmPassword
if not. -
Form handling:
ThehandleSubmit
function takes form data, converts it to a plain object, and callssignupSchema.safeParse(data)
.- If validation fails, it loops through the errors and displays a message for each failed field.
- If validation passes, the data is guaranteed to match your schema and is passed to your submit logic.
This approach ensures that:
- User input is validated (and sanitized/coerced) before use.
- Errors are specific and helpful, tied to each problematic field.
- Your TypeScript types always match your runtime validations, preventing subtle bugs.
Database models:
const userDbSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.email(),
role: z.enum(['admin', 'user', 'guest']),
created_at: z.date(),
updated_at: z.date()
})
type User = z.infer<typeof userDbSchema>
// Validate data from database
function getUser(id: string): User {
const row = db.query('SELECT * FROM users WHERE id = ?', [id])
return userDbSchema.parse(row)
}
// Insert schema (omit generated fields)
const insertUserSchema = userDbSchema.omit({
id: true,
created_at: true,
updated_at: true
})
type InsertUser = z.infer<typeof insertUserSchema>
function createUser(data: InsertUser) {
const validated = insertUserSchema.parse(data)
db.query('INSERT INTO users ...', validated)
}
The above example demonstrates how to use Zod to define and enforce types for data stored in, or loaded from, a database.
-
Defining the schema:
userDbSchema
describes the expected structure of a user record, including typical fields (likeid
,name
,email
), specific value restrictions (such asid
being a UUID androle
being one of a predefined set), and automatic fields likecreated_at
andupdated_at
of typedate
. -
Inferring types:
type User = z.infer<typeof userDbSchema>
automatically creates a TypeScript type perfectly matching the schema, so your code and database structure remain synchronized. -
Validating DB data:
In thegetUser
function, after fetching a row from the database,userDbSchema.parse(row)
checks at runtime that all properties have the expected types and formats. If the data is malformed, Zod throws an error—catching subtle bugs early that static types alone can’t prevent. -
Insert schemas:
Often, when inserting new records (e.g. creating a user), you don’t want the client to supply values for database-generated fields (id
,created_at
,updated_at
).
By using.omit(...)
, you derive aninsertUserSchema
that only allows legitimate, required fields from the client.
ThecreateUser
function then validates incoming insert data, guaranteeing that only safe, valid values reach your database logic.
In summary, this pattern uses Zod to ensure your application is type-safe and resilient both at compile time (via TypeScript) and at runtime (via Zod validation), while avoiding duplicated types and minimizing security risks from unchecked data.
Configuration files:
const configSchema = z.object({
server: z.object({
port: z.number().default(3000),
host: z.string().default('localhost')
}),
database: z.object({
url: z.string().url(),
poolSize: z.number().min(1).max(100).default(10)
}),
features: z.object({
authentication: z.boolean().default(true),
analytics: z.boolean().default(false)
})
})
const config = configSchema.parse(JSON.parse(configFile))
The above example shows how to use Zod to validate configuration files (like those written in JSON or loaded from disk) before using their values in your application.
-
Defining the config schema:
TheconfigSchema
describes the expected structure and types of the entire configuration object, including nested sections likeserver
,database
, andfeatures
. Each property inside these objects has constraints, type checks, and sensible defaults (e.g., default port 3000, default host'localhost'
, required valid URLs, minimum/maximum values, and boolean defaults). -
Parsing the config:
By parsing the raw loaded config data (e.g.,JSON.parse(configFile)
) throughconfigSchema.parse()
, you ensure that the configuration truly matches your expectations:- All required fields are present (or defaulted)
- Values have the correct type and format (such as numbers for ports, valid URLs, etc.)
- Unknown or misspelled properties trigger validation errors
-
Robustness and safety:
This approach eliminates common runtime config bugs—such as typos, missing values, or wrong types—before your program even starts. If the file is invalid, Zod will throw a detailed error, likely halting execution early and showing clear messages about what needs fixing. -
Type inference:
After parsing,config
is fully typed according to your schema, allowing TypeScript to provide complete autocomplete and safety throughout the rest of your codebase.
This pattern is highly recommended for production-grade applications, CLI tools, or anything relying on user-provided configuration files, as it proactively prevents misconfiguration and runtime surprises.
I wrote 20 books to help you become a better developer:
- JavaScript Handbook
- TypeScript Handbook
- CSS Handbook
- Node.js Handbook
- Astro Handbook
- HTML Handbook
- Next.js Pages Router Handbook
- Alpine.js Handbook
- HTMX Handbook
- React Handbook
- SQL Handbook
- Git Cheat Sheet
- Laravel Handbook
- Express Handbook
- Swift Handbook
- Go Handbook
- PHP Handbook
- Python Handbook
- Linux/Mac CLI Commands Handbook
- C Handbook