Tools

Zod: TypeScript Schema Validation

Mayur Dabhi
Mayur Dabhi
June 19, 2026
14 min read

TypeScript gives you compile-time type safety, but there's a problem: your types disappear at runtime. The moment data crosses a system boundary — a network request, a form submission, an environment variable — TypeScript can't help you. You trust that the JSON body has the right shape, but if it doesn't, you get a runtime error that no amount of interface declarations would have caught. Zod solves this by letting you define a schema once and getting both runtime validation and compile-time type inference from the same declaration.

Why Zod Has Taken Over

Zod is downloaded over 8 million times per week on npm and is the default validation library in tRPC, the recommended resolver for React Hook Form, and is used internally by Vercel, Prisma, and Next-Auth. Understanding Zod is increasingly non-negotiable for full-stack TypeScript development.

The Problem Zod Solves

Consider a typical TypeScript API handler. You define an interface for the expected request body, but at runtime you're actually receiving unknown data from the network. The TypeScript compiler trusts your type assertion — it has no choice — but the data might be completely wrong:

Without Zod — dangerous type assertion
interface CreateUserDto {
  name: string;
  email: string;
  age: number;
}

// This compiles fine but crashes at runtime if body is malformed
app.post('/users', (req, res) => {
  const body = req.body as CreateUserDto; // ← blind trust
  const user = db.createUser(body.name, body.email, body.age);
  res.json(user);
});

The real problem is that you have two separate sources of truth: your TypeScript interface and your runtime validation logic (if you even bother to write it). Zod collapses these into one. You write the schema once, and Zod infers the TypeScript type automatically.

WITHOUT ZOD TypeScript Interface Compile-time only Manual if/throw checks Runtime validation (duplicated) duplicated WITH ZOD z.object({ ... }) Schema Single source of truth TypeScript Type Auto-inferred Runtime Validator parse / safeParse Zero duplication — always in sync

Zod eliminates the gap between compile-time types and runtime validation

Installation and First Steps

1

Install Zod

Zod requires TypeScript 4.5+ with strict mode enabled in your tsconfig.json.

Terminal
# npm
npm install zod

# pnpm
pnpm add zod

# yarn
yarn add zod
2

Enable strict mode in tsconfig.json

Zod's type inference works best with TypeScript strict mode. Add "strict": true to your compilerOptions.

3

Define your first schema

Import z from zod and start building schemas. Use z.infer to extract the TypeScript type — no separate interface needed.

your-first-schema.ts
import { z } from 'zod';

// Define the schema
const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
});

// Extract the TypeScript type from the schema — no interface duplication!
type User = z.infer<typeof UserSchema>;
// Equivalent to: { name: string; email: string; age: number }

// parse() throws a ZodError if validation fails
const user = UserSchema.parse({
  name: 'Alice',
  email: 'alice@example.com',
  age: 30,
});

// safeParse() never throws — returns a discriminated union
const result = UserSchema.safeParse({ name: 'Bob', email: 'not-an-email', age: -1 });
if (!result.success) {
  console.log(result.error.issues);
  // [{ code: 'invalid_string', message: 'Invalid email', path: ['email'] },
  //  { code: 'too_small', message: 'Number must be >= 0', path: ['age'] }]
}
parse vs safeParse

Use .parse() when you want to throw on invalid data (middleware, server startup). Use .safeParse() when you need to handle errors gracefully and return them to a client — it returns { success: true, data } | { success: false, error } and never throws.

Core Schema Types

Zod covers every primitive and composite type you'll encounter in TypeScript. Here's a comprehensive reference for the types you'll use most often:

import { z } from 'zod';

// Strings
z.string()
z.string().min(3).max(100)
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(/^[a-z]+$/)
z.string().trim()             // strips whitespace before validation
z.string().toLowerCase()      // transform: to lowercase

// Numbers
z.number()
z.number().int()
z.number().positive()
z.number().min(0).max(100)
z.number().finite()
z.number().multipleOf(5)

// Other primitives
z.boolean()
z.date()
z.bigint()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
import { z } from 'zod';

// Objects
const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  price: z.number().positive(),
  tags: z.array(z.string()),
});

// Nested objects
const OrderSchema = z.object({
  product: ProductSchema,
  quantity: z.number().int().positive(),
});

// Arrays
z.array(z.string())               // string[]
z.array(z.number()).min(1).max(5)  // 1–5 numbers
z.string().array()                 // shorthand

// Tuples — fixed-length, typed per position
const PointSchema = z.tuple([z.number(), z.number()]);
type Point = z.infer<typeof PointSchema>; // [number, number]

// Records — like a TypeScript Record<K, V>
const ScoresSchema = z.record(z.string(), z.number());
type Scores = z.infer<typeof ScoresSchema>; // Record<string, number>

// Maps and Sets
z.map(z.string(), z.number())
z.set(z.string())
import { z } from 'zod';

// Enums
const StatusSchema = z.enum(['active', 'inactive', 'pending']);
type Status = z.infer<typeof StatusSchema>; // 'active' | 'inactive' | 'pending'

// Native TypeScript enums
enum Direction { Up, Down, Left, Right }
const DirectionSchema = z.nativeEnum(Direction);

// Unions
const StringOrNumber = z.union([z.string(), z.number()]);

// Discriminated unions — faster, better errors
const ResultSchema = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.string() }),
  z.object({ status: z.literal('error'), message: z.string() }),
]);

// Intersections
const WithTimestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});
const UserWithTimestamps = UserSchema.and(WithTimestamps);

// Literals
z.literal('hello')
z.literal(42)
z.literal(true)
import { z } from 'zod';

const BaseSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  bio: z.string(),
});

// Optional — adds | undefined
z.string().optional()            // string | undefined

// Nullable — adds | null
z.string().nullable()            // string | null

// Nullish — adds | null | undefined
z.string().nullish()

// Default — provides a fallback value
z.string().default('anonymous')
z.boolean().default(false)

// Object modifiers
const UpdateSchema = BaseSchema.partial();     // All fields optional
const RequiredSchema = BaseSchema.required();  // All fields required
const PickedSchema = BaseSchema.pick({ name: true });   // { name }
const OmittedSchema = BaseSchema.omit({ bio: true });   // { id, name }
const ExtendedSchema = BaseSchema.extend({
  email: z.string().email(),
});

// Passthrough — allow extra keys (stripped by default)
BaseSchema.passthrough()
BaseSchema.strict()    // throw on extra keys
BaseSchema.strip()     // default: silently remove extra keys

Refinements and Transformations

Zod's real power emerges when you need validation logic beyond simple type checks. .refine() adds custom validation, while .transform() lets you reshape data as part of the parsing step.

refinements-and-transforms.ts
import { z } from 'zod';

// .refine() — custom validation predicate
const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .refine(
    (val) => /[A-Z]/.test(val),
    { message: 'Password must contain at least one uppercase letter' }
  )
  .refine(
    (val) => /[0-9]/.test(val),
    { message: 'Password must contain at least one number' }
  );

// Cross-field validation with .superRefine()
const SignupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
});

// .transform() — parse and reshape data
const StringToNumber = z.string().transform((val) => parseInt(val, 10));
type StringToNumber = z.infer<typeof StringToNumber>; // number (output type)

// Chaining transforms
const TrimmedEmail = z.string()
  .trim()
  .toLowerCase()
  .email();

// Pipe — compose schemas
const CoercedDate = z.string()
  .pipe(z.coerce.date()); // parse string → Date object

// z.coerce — automatic type coercion (great for query params)
z.coerce.number()    // "42" → 42
z.coerce.boolean()   // "true" → true, "1" → true
z.coerce.date()      // "2026-01-01" → Date object
.transform() Changes the Inferred Type

When you use .transform(), z.infer gives you the output type, not the input. If you need both, use z.input<typeof Schema> for the input type and z.output<typeof Schema> for the output type. This matters for API request vs response schemas.

Form Validation with React Hook Form

Zod integrates seamlessly with React Hook Form through the @hookform/resolvers package. This combination eliminates all the boilerplate of manual form validation and gives you a single schema that drives both your UI validation messages and your submit handler types.

Terminal — install resolver
npm install @hookform/resolvers react-hook-form
RegistrationForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const RegistrationSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username cannot exceed 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
  email: z.string().email('Please enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string(),
  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: 'You must accept the terms' }),
  }),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
});

type RegistrationFormData = z.infer<typeof RegistrationSchema>;

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(RegistrationSchema),
  });

  const onSubmit = async (data: RegistrationFormData) => {
    // data is fully typed — TypeScript knows every field's type
    await registerUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('username')} placeholder="Username" />
        {errors.username && <p>{errors.username.message}</p>}
      </div>

      <div>
        <input {...register('email')} type="email" placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <input {...register('password')} type="password" placeholder="Password" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>

      <div>
        <input {...register('confirmPassword')} type="password" placeholder="Confirm" />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>

      <div>
        <input {...register('acceptTerms')} type="checkbox" />
        <label>I accept the terms and conditions</label>
        {errors.acceptTerms && <p>{errors.acceptTerms.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}
Zod Schema z.object({}) resolver React Hook Form useForm() Type-safe onSubmit(data) Error Messages errors.field.message React UI Validated form

Zod schema drives both TypeScript types and React Hook Form validation

API Input Validation

Validating API input is where Zod truly shines. Every request handler that accepts user-controlled data is a security and correctness boundary. Zod makes it trivial to validate and parse that boundary with proper error handling.

Express Middleware

middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

// Generic validation middleware factory
export const validate = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    const result = await schema.safeParseAsync({
      body: req.body,
      query: req.query,
      params: req.params,
    });

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues.map((issue) => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }

    // Attach validated/transformed data back to req
    req.body = result.data.body;
    next();
  };

// Usage in routes
import { z } from 'zod';

const CreatePostSchema = z.object({
  body: z.object({
    title: z.string().min(5).max(200),
    content: z.string().min(50),
    tags: z.array(z.string()).max(10).default([]),
    published: z.boolean().default(false),
  }),
});

router.post('/posts', validate(CreatePostSchema), async (req, res) => {
  // req.body is fully typed and validated here
  const post = await Post.create(req.body);
  res.status(201).json(post);
});

Next.js API Routes

pages/api/contact.ts (Next.js)
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  subject: z.string().min(5).max(200),
  message: z.string().min(20).max(2000),
});

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method Not Allowed' });
  }

  const result = ContactSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Invalid request',
      details: result.error.flatten().fieldErrors,
    });
  }

  // result.data is typed as { name: string; email: string; ... }
  await sendContactEmail(result.data);
  res.status(200).json({ success: true });
}

Advanced Patterns

Once you're comfortable with the basics, Zod has several advanced features that solve real-world architectural problems.

Environment Variable Validation

One of the most valuable applications of Zod is validating environment variables at application startup. This catches misconfiguration immediately — before any requests are served — with clear error messages.

env.ts — validate at startup
import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  PORT: z.coerce.number().int().min(1024).max(65535).default(3000),
  REDIS_URL: z.string().url().optional(),
  ALLOWED_ORIGINS: z.string()
    .transform((val) => val.split(',').map((s) => s.trim())),
});

// Parse once at startup — throws with a clear error if misconfigured
const env = EnvSchema.parse(process.env);

// Export typed env — all fields have their correct TypeScript types
export default env;

// Usage:
// import env from './env';
// env.PORT        // number
// env.DATABASE_URL // string (URL-validated)
// env.ALLOWED_ORIGINS // string[] (transformed)

Recursive Schemas

recursive-schema.ts
import { z } from 'zod';

// Recursive type for a tree structure (e.g., nested comments, menu items)
type Category = {
  id: string;
  name: string;
  children: Category[];
};

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string().uuid(),
    name: z.string(),
    children: z.array(CategorySchema),
  })
);

// Branded types — add type-level uniqueness
const UserId = z.string().uuid().brand<'UserId'>();
const PostId = z.string().uuid().brand<'PostId'>();

type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;

function getUser(id: UserId) { /* ... */ }

const postId = PostId.parse('some-uuid');
// getUser(postId); // TypeScript error! PostId is not assignable to UserId

Comparison: Zod vs Alternatives

Feature Zod Yup Joi io-ts
TypeScript-first ✅ Yes ⚠️ Partial ⚠️ Partial ✅ Yes
Type inference ✅ Automatic ❌ Manual ❌ Manual ✅ Automatic
Bundle size ~14 kB ~38 kB ~24 kB ~5 kB
Transformations ✅ Built-in ✅ Built-in ✅ Built-in ⚠️ Verbose
Error messages ✅ Excellent ✅ Good ✅ Good ⚠️ Terse
tRPC integration ✅ Native ⚠️ Via adapter ⚠️ Via adapter ⚠️ Via adapter
Learning curve Low Low Medium High

Real-World Example: Full API Contract

Here's a complete example showing how to use Zod to define shared schemas between your frontend and backend — the pattern that makes tRPC and full-stack TypeScript frameworks so powerful:

schemas/post.ts — shared frontend/backend schemas
import { z } from 'zod';

// Base shape — shared validation rules
const PostBase = z.object({
  title: z.string().min(5, 'Title too short').max(200, 'Title too long'),
  content: z.string().min(50, 'Post must have at least 50 characters'),
  excerpt: z.string().max(300).optional(),
  tags: z.array(z.string().toLowerCase().trim()).max(10).default([]),
  published: z.boolean().default(false),
  publishAt: z.coerce.date().optional(),
});

// Create — what the client sends
export const CreatePostSchema = PostBase;
export type CreatePostInput = z.infer<typeof CreatePostSchema>;

// Update — all fields optional except id
export const UpdatePostSchema = PostBase.partial().extend({
  id: z.string().uuid(),
});
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>;

// Response — what the server returns (adds generated fields)
export const PostResponseSchema = PostBase.extend({
  id: z.string().uuid(),
  slug: z.string(),
  authorId: z.string().uuid(),
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
  viewCount: z.number().int().min(0),
});
export type PostResponse = z.infer<typeof PostResponseSchema>;

// List query params
export const ListPostsQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  tag: z.string().optional(),
  published: z.coerce.boolean().optional(),
  search: z.string().max(200).optional(),
});
export type ListPostsQuery = z.infer<typeof ListPostsQuerySchema>;

// In the API route:
// const query = ListPostsQuerySchema.parse(req.query);
// query.page   → number (coerced from string query param)
// query.limit  → number
// query.published → boolean | undefined

ZodError — Understanding Error Structure

Zod provides multiple ways to format and work with validation errors:

const result = CreatePostSchema.safeParse({
  title: 'Hi',         // too short
  content: 'Short',    // too short
  tags: ['JS', 'JS'],  // duplicates (if you add a refine)
});

if (!result.success) {
  // .issues — full detail array
  result.error.issues;
  // [
  //   { code: 'too_small', path: ['title'], message: 'Title too short' },
  //   { code: 'too_small', path: ['content'], message: 'Post must have...' }
  // ]

  // .format() — nested object matching input shape
  result.error.format();
  // { title: { _errors: ['Title too short'] }, content: { _errors: [...] } }

  // .flatten() — flat field → messages map (great for APIs)
  result.error.flatten();
  // {
  //   formErrors: [],
  //   fieldErrors: { title: ['Title too short'], content: ['Post must have...'] }
  // }
}

Key Takeaways

What You've Learned

  • Single source of truth: Define schemas once, get TypeScript types and runtime validation for free via z.infer
  • parse vs safeParse: Use .parse() to throw, .safeParse() to handle errors gracefully in a discriminated union
  • Full type coverage: Zod handles primitives, objects, arrays, unions, enums, tuples, records, and recursive types
  • Transformations: .transform() and z.coerce let you clean and reshape data as part of parsing
  • Form integration: Pair with React Hook Form via zodResolver to eliminate validation boilerplate entirely
  • API boundaries: Validate every external input — HTTP request bodies, query params, environment variables, localStorage
  • Advanced patterns: Branded types, recursive schemas, and discriminated unions for domain-accurate modeling
"Write the schema once. The types and validation follow automatically. That's the Zod philosophy — and it's why TypeScript developers never go back."

Zod has become the validation standard in the TypeScript ecosystem for good reason: it solves a real problem elegantly. Once you adopt the practice of defining schemas at every data boundary — API inputs, form submissions, environment variables, storage reads — you eliminate an entire class of runtime bugs. Your types and your runtime validation are always in sync because they're the same thing. Start adding Zod to your most error-prone data boundaries today and you'll quickly wonder how you shipped TypeScript apps without it.

Zod TypeScript Validation Schema React Hook Form Type Safety
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.