← All articles
TYPESCRIPT Zod: TypeScript-First Schema Validation for Any Runtime 2026-03-04 · 4 min read · zod · validation · typescript

Zod: TypeScript-First Schema Validation for Any Runtime

TypeScript 2026-03-04 · 4 min read zod validation typescript schema runtime types parsing

TypeScript gives you static type checking at compile time. Zod solves the complementary problem: validating data at runtime that comes from outside TypeScript's control — HTTP request bodies, API responses, environment variables, user input, and config files.

The key insight is that Zod schemas are the source of truth for both runtime validation and TypeScript types. You write the schema once and get both.

The Core Problem Zod Solves

Without runtime validation:

// TypeScript trusts this at compile time
const body = await req.json() as { name: string; age: number }
// But req.json() returns `any` — the cast is a lie
// At runtime, name could be undefined, age could be a string
body.name.toUpperCase()  // may throw at runtime

With Zod:

const UserSchema = z.object({
  name: z.string(),
  age: z.number()
})

const result = UserSchema.safeParse(await req.json())
if (!result.success) {
  return new Response('Invalid input', { status: 400 })
}
// result.data is correctly typed as { name: string; age: number }
result.data.name.toUpperCase()  // safe

Installation

npm install zod
# or
bun add zod

No peer dependencies. Works in Node.js, Deno, Bun, Cloudflare Workers, and browsers.

Basic Types

import { z } from 'zod'

// Primitives
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.any()
z.unknown()
z.never()

// Literals
z.literal('hello')
z.literal(42)
z.literal(true)

// Type inference
const StringSchema = z.string()
type StringType = z.infer<typeof StringSchema>  // string

Objects and Arrays

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'user', 'moderator']),
  createdAt: z.date()
})

type User = z.infer<typeof UserSchema>
// {
//   id: string;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "user" | "moderator";
//   createdAt: Date;
// }

const TagsSchema = z.array(z.string()).min(1).max(10)
type Tags = z.infer<typeof TagsSchema>  // string[]

parse vs. safeParse

Two ways to validate:

// parse: throws ZodError on failure
try {
  const user = UserSchema.parse(rawData)
  // user is typed as User
} catch (err) {
  if (err instanceof z.ZodError) {
    console.log(err.issues)  // array of validation issues
  }
}

// safeParse: returns discriminated union, never throws
const result = UserSchema.safeParse(rawData)
if (result.success) {
  const user = result.data  // typed as User
} else {
  console.log(result.error.issues)
}

safeParse is preferred in most application code — it handles errors without exceptions. parse is useful in startup/init code where a failure is truly fatal and you want to crash immediately.

Schema Composition

// Extending objects
const BaseSchema = z.object({
  id: z.string().uuid(),
  createdAt: z.date()
})

const PostSchema = BaseSchema.extend({
  title: z.string(),
  content: z.string()
})

// Picking/omitting fields
const CreatePostSchema = PostSchema.omit({ id: true, createdAt: true })
const PostPreviewSchema = PostSchema.pick({ id: true, title: true })

// Making all fields optional
const UpdatePostSchema = PostSchema.partial()

// Making all fields required
const RequiredSchema = PostSchema.required()

Unions and Discriminated Unions

// Union (any of these types)
const StringOrNumber = z.union([z.string(), z.number()])

// Discriminated union (better performance and error messages)
const EventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keypress'), key: z.string() }),
  z.object({ type: z.literal('submit'), formId: z.string() })
])

type Event = z.infer<typeof EventSchema>

Discriminated unions give better error messages — when parsing fails, Zod knows which variant to show the error for based on the discriminator field.

Transforms and Preprocessing

// Transform: validate then convert
const NumberStringSchema = z.string().transform((val) => parseInt(val, 10))
// input: string, output: number

// Preprocess: convert before validating
const FlexibleNumberSchema = z.preprocess(
  (val) => (typeof val === 'string' ? parseFloat(val) : val),
  z.number()
)
// accepts both "42" and 42

// Chaining transforms
const TrimmedEmailSchema = z.string()
  .transform((s) => s.trim().toLowerCase())
  .pipe(z.string().email())

Validation Refinements

const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase letter')
  .refine((val) => /[0-9]/.test(val), 'Must contain a number')

// Cross-field validation
const SignupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string()
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords do not match', path: ['confirmPassword'] }
)

Practical Patterns

API request validation (Hono/Express)

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user')
})

app.post('/users', async (c) => {
  const result = CreateUserSchema.safeParse(await c.req.json())
  if (!result.success) {
    return c.json({ error: result.error.flatten() }, 400)
  }
  const user = await createUser(result.data)
  return c.json(user, 201)
})

Environment variable validation

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  JWT_SECRET: z.string().min(32)
})

const env = EnvSchema.parse(process.env)
// Throws at startup if env is misconfigured
// env is fully typed: { DATABASE_URL: string; PORT: number; ... }

Form validation with error messages

const ContactFormSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message too short').max(1000)
})

// Get flat error messages per field:
const result = ContactFormSchema.safeParse(formData)
if (!result.success) {
  const errors = result.error.flatten().fieldErrors
  // errors.email = ['Invalid email address']
  // errors.message = ['Message too short']
}

Error Formatting

const result = schema.safeParse(data)
if (!result.success) {
  // All issues as an array
  result.error.issues
  
  // Flat format: { fieldErrors: {...}, formErrors: [...] }
  result.error.flatten()
  
  // Nested format matching input shape
  result.error.format()
  
  // Pretty-printed for debugging
  console.log(result.error.toString())
}

Zod vs. Alternatives

Zod Yup Joi io-ts
TypeScript inference ✓ Excellent ✓ Good ✗ Limited ✓ Good
Bundle size ~15KB ~20KB ~25KB ~10KB
Error messages Clear Clear Verbose Technical
Transforms
Async validation
Ecosystem Excellent Good Good Limited

Zod has become the de facto standard in the TypeScript ecosystem. tRPC, T3 Stack, Hono, Next.js form examples, and most TypeScript API frameworks recommend Zod. When in doubt, start with Zod.


Subscribe to DevTools Guide Newsletter for more developer tooling guides.