How to use Zod with TypeScript: validate data and infer types

By

Learn Zod 4 schema validation with TypeScript using parse, safeParse, inferred types, records, preprocessing, coercion, and practical API examples.

~~~

Zod is a schema validation library that checks data while your program runs. With TypeScript, you can also infer a type from the same schema. Use it to check API responses, form values, or configuration before your code uses them.

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: 'flavio@example.com'
})

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.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about typescript: