Skip to content

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:

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:

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:

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:

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:

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.

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.

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.

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.

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.

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:

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:

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:

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.

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:

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:

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:

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:

This approach ensures that:

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.

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.

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.


→ Get my JavaScript Beginner's Handbook

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
...download them all now!

Related posts about typescript: