Skip to main content
Ganesh Joshi
Back to Blogs

Zod for validation and TypeScript inference

February 15, 20265 min read
Tutorials
TypeScript or validation code on screen

Zod is a TypeScript-first schema validation library. It solves a common problem: TypeScript types only exist at compile time, but you need to validate data at runtime—API responses, form input, config files, and other external data. Zod validates runtime data and infers TypeScript types from the same schema, keeping them in sync.

Why Zod?

Problem Zod solution
API data is any Parse with schema, get typed data
Form input untrusted Validate before processing
Types and validation diverge Single source of truth
Verbose validation code Declarative schema definition
// Without Zod: types and validation separate
interface User {
  name: string;
  email: string;
}

function validateUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'name' in data &&
    typeof data.name === 'string' &&
    'email' in data &&
    typeof data.email === 'string'
  );
}

// With Zod: single schema, types inferred
const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

Installation

npm install zod

Zod has no dependencies and works in any JavaScript environment.

Basic schemas

Primitives

import { z } from 'zod';

const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const bigintSchema = z.bigint();
const undefinedSchema = z.undefined();
const nullSchema = z.null();

Objects

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

type User = z.infer<typeof UserSchema>;

Arrays

const StringArraySchema = z.array(z.string());
const UserArraySchema = z.array(UserSchema);

// With constraints
const TagsSchema = z.array(z.string()).min(1).max(10);

Unions and enums

// Union
const ResultSchema = z.union([
  z.object({ status: z.literal('success'), data: z.string() }),
  z.object({ status: z.literal('error'), message: z.string() }),
]);

// Discriminated union (more efficient)
const ResultSchema = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.string() }),
  z.object({ status: z.literal('error'), message: z.string() }),
]);

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

Parsing data

parse vs safeParse

// parse: throws on invalid data
try {
  const user = UserSchema.parse(unknownData);
  // user is typed as User
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues);
  }
}

// safeParse: returns result object
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  const user = result.data; // User
} else {
  const errors = result.error.issues;
}

Use safeParse for form validation where you want to show errors. Use parse when invalid data is unexpected.

Error formatting

const result = UserSchema.safeParse(formData);

if (!result.success) {
  // Flat errors by path
  const fieldErrors = result.error.flatten().fieldErrors;
  // { name: ['Required'], email: ['Invalid email'] }

  // Formatted errors
  const formatted = result.error.format();
  // { name: { _errors: ['Required'] }, email: { _errors: ['Invalid email'] } }
}

String validations

z.string().min(1, 'Required')
z.string().max(100, 'Too long')
z.string().length(5)
z.string().email('Invalid email')
z.string().url('Invalid URL')
z.string().uuid()
z.string().regex(/^[a-z]+$/, 'Lowercase only')
z.string().startsWith('https://')
z.string().endsWith('.com')
z.string().trim()  // Transform: trims whitespace
z.string().toLowerCase()  // Transform: lowercases

Number validations

z.number().int()
z.number().positive()
z.number().negative()
z.number().nonnegative()
z.number().min(0)
z.number().max(100)
z.number().multipleOf(5)
z.number().finite()
z.number().safe()  // JavaScript safe integer

Optional and nullable

// Optional: can be undefined
z.string().optional()  // string | undefined

// Nullable: can be null
z.string().nullable()  // string | null

// Both
z.string().nullish()  // string | null | undefined

// With default
z.string().default('anonymous')
z.number().default(() => Date.now())

Transformations

// Coerce strings to numbers
const NumberFromString = z.coerce.number();
NumberFromString.parse('42');  // 42

// Custom transform
const TrimmedString = z.string().transform((s) => s.trim());

// Transform with refinement
const PositiveString = z
  .string()
  .transform((s) => parseInt(s, 10))
  .refine((n) => n > 0, 'Must be positive');

Custom validation

// Simple refinement
const PasswordSchema = z
  .string()
  .min(8)
  .refine(
    (password) => /[A-Z]/.test(password),
    'Must contain uppercase letter'
  )
  .refine(
    (password) => /[0-9]/.test(password),
    'Must contain number'
  );

// Superrefine for complex validation
const FormSchema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Passwords must match',
        path: ['confirmPassword'],
      });
    }
  });

Composition

Extending objects

const BaseUserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});

const AdminSchema = BaseUserSchema.extend({
  role: z.literal('admin'),
  permissions: z.array(z.string()),
});

Pick and omit

const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });

const UserEmailSchema = UserSchema.pick({ email: true });

Partial and required

// All fields optional
const PartialUserSchema = UserSchema.partial();

// All fields required
const RequiredUserSchema = PartialUserSchema.required();

// Specific fields optional
const UpdateUserSchema = UserSchema.partial({ name: true, age: true });

With React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const FormSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Too short'),
});

type FormData = z.infer<typeof FormSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(FormSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Login</button>
    </form>
  );
}

With Server Actions

'use server';

import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(1, 'Required'),
  email: z.string().email('Invalid email'),
  message: z.string().min(10, 'Too short'),
});

export async function submitContact(prevState: any, formData: FormData) {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = ContactSchema.safeParse(raw);

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      success: false,
    };
  }

  await saveContact(result.data);

  return { errors: null, success: true };
}

API validation

// app/api/users/route.ts
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

export async function POST(request: Request) {
  const body = await request.json();
  const result = CreateUserSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { error: result.error.flatten() },
      { status: 400 }
    );
  }

  const user = await createUser(result.data);
  return Response.json(user, { status: 201 });
}

Summary

Zod validates data at runtime and infers TypeScript types from the same schema. Define schemas with primitives, objects, arrays, and unions. Use safeParse for form validation, parse for unexpected failures. Add refinements for custom validation. Compose schemas with extend, pick, omit, and partial. Integrate with React Hook Form using zodResolver and with Server Actions by parsing FormData.

Frequently Asked Questions

Zod is a TypeScript-first schema validation library. You define schemas that describe data shapes, and Zod validates data at runtime while inferring TypeScript types from those schemas.

TypeScript types are compile-time only—they disappear at runtime. Zod validates data at runtime (API responses, form input, external data) while also generating TypeScript types.

z.infer extracts the TypeScript type from a Zod schema. This keeps your runtime validation and TypeScript types in sync—change the schema, and the type updates automatically.

Use safeParse for form validation and API handlers where you want to handle errors gracefully. Use parse when invalid data should throw an error (unexpected situations).

Yes. Use @hookform/resolvers with zodResolver to validate form data against a Zod schema. You get runtime validation and TypeScript types from the same schema definition.

Related Posts