How to use Zod 4 for TypeScript validation

By

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.

Tagged: TypeScript · All topics
~~~

Related posts about typescript: