Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide12 min read

Zod

Zod is a TypeScript-first schema validation library with automatic type inference. Learn schemas, transformations, React Hook Form integration, and Server Actions.

Zod - Complete guide to TypeScript-first validation

What is Zod and why did it revolutionize validation?

Zod is a data validation library built specifically for TypeScript. Unlike traditional validation libraries (like Joi or Yup), Zod offers full type inference - you define a schema once, and TypeScript automatically derives the corresponding type.

This means the end of duplication: you no longer need to write a TypeScript interface separately from a validation schema. Zod does both at once.

Why Zod?

TypeScript-first

Code
TypeScript
import { z } from 'zod'

// You define the schema ONCE
const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18).optional(),
})

// TypeScript automatically derives the type
type User = z.infer<typeof UserSchema>
// {
//   name: string
//   email: string
//   age?: number | undefined
// }

// No need to write this separately!

Zero dependencies

Zod has no runtime dependencies. The package weighs ~50kb (minified), ~12kb (gzipped).

Works everywhere

  • Node.js
  • Browsers
  • React Native
  • Edge runtimes (Vercel, Cloudflare)

Installation

Code
Bash
npm install zod

# Or with yarn/pnpm
yarn add zod
pnpm add zod

Basic schemas

Primitive types

Code
TypeScript
import { z } from 'zod'

// String
const stringSchema = z.string()

// Number
const numberSchema = z.number()

// Boolean
const booleanSchema = z.boolean()

// Date
const dateSchema = z.date()

// BigInt
const bigintSchema = z.bigint()

// Symbol
const symbolSchema = z.symbol()

// Undefined
const undefinedSchema = z.undefined()

// Null
const nullSchema = z.null()

// Void (undefined or null)
const voidSchema = z.void()

// Any (avoid if possible)
const anySchema = z.any()

// Unknown (safer than any)
const unknownSchema = z.unknown()

// Never
const neverSchema = z.never()

String validations

Code
TypeScript
const stringSchema = z.string()
  .min(1, 'Required')
  .max(100, 'Maximum 100 characters')
  .email('Invalid email')
  .url('Invalid URL')
  .uuid('Invalid UUID')
  .cuid()
  .cuid2()
  .ulid()
  .regex(/^[a-z]+$/, 'Lowercase letters only')
  .includes('hello')
  .startsWith('https://')
  .endsWith('.com')
  .datetime()  // ISO 8601
  .ip()        // IPv4 or IPv6
  .trim()      // Trims whitespace before validation
  .toLowerCase()
  .toUpperCase()

// Usage examples
const EmailSchema = z.string().email()
const PasswordSchema = z.string()
  .min(8, 'Minimum 8 characters')
  .regex(/[A-Z]/, 'Requires an uppercase letter')
  .regex(/[0-9]/, 'Requires a digit')
  .regex(/[^A-Za-z0-9]/, 'Requires a special character')

Number validations

Code
TypeScript
const numberSchema = z.number()
  .gt(5)       // > 5
  .gte(5)      // >= 5
  .lt(10)      // < 10
  .lte(10)     // <= 10
  .int()       // integers only
  .positive()  // > 0
  .nonnegative() // >= 0
  .negative()  // < 0
  .nonpositive() // <= 0
  .multipleOf(5) // 5, 10, 15...
  .finite()    // not Infinity
  .safe()      // Number.MIN_SAFE_INTEGER - MAX_SAFE_INTEGER

// Examples
const AgeSchema = z.number().int().min(0).max(150)
const PriceSchema = z.number().positive().multipleOf(0.01)

Objects

Code
TypeScript
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
  role: z.enum(['admin', 'user', 'guest']),
  metadata: z.record(z.string()), // { [key: string]: string }
})

type User = z.infer<typeof UserSchema>

// Partial - all fields optional
const PartialUserSchema = UserSchema.partial()

// Required - all fields required
const RequiredUserSchema = UserSchema.required()

// Pick - select only certain fields
const UserNameSchema = UserSchema.pick({ name: true, email: true })

// Omit - skip certain fields
const UserWithoutIdSchema = UserSchema.omit({ id: true })

// Extend - extend the schema
const AdminSchema = UserSchema.extend({
  permissions: z.array(z.string()),
  department: z.string(),
})

// Merge - combine two schemas
const CombinedSchema = UserSchema.merge(z.object({
  address: z.string(),
}))

// passthrough - allow additional fields
const FlexibleSchema = UserSchema.passthrough()

// strict - error on additional fields
const StrictSchema = UserSchema.strict()

// strip - remove additional fields (default)
const StrippedSchema = UserSchema.strip()

Arrays

Code
TypeScript
const StringArraySchema = z.array(z.string())

const NumberArraySchema = z.array(z.number())
  .min(1, 'Minimum 1 element')
  .max(10, 'Maximum 10 elements')
  .length(5, 'Exactly 5 elements')
  .nonempty('Array cannot be empty')

// Tuple - array with fixed length and types
const CoordinatesSchema = z.tuple([z.number(), z.number()])
type Coordinates = z.infer<typeof CoordinatesSchema> // [number, number]

// Tuple with rest
const ArgsSchema = z.tuple([z.string(), z.number()]).rest(z.boolean())
// [string, number, ...boolean[]]

Unions and literals

Code
TypeScript
// Literal
const StatusSchema = z.literal('active')
type Status = z.infer<typeof StatusSchema> // 'active'

// Enum (z.enum)
const RoleSchema = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof RoleSchema> // 'admin' | 'user' | 'guest'

// Accessing enum values
RoleSchema.options // ['admin', 'user', 'guest']
RoleSchema.enum // { admin: 'admin', user: 'user', guest: 'guest' }

// Native enum
enum NativeRole {
  Admin = 'admin',
  User = 'user',
}
const NativeRoleSchema = z.nativeEnum(NativeRole)

// Union
const StringOrNumberSchema = z.union([z.string(), z.number()])
// or shorter:
const StringOrNumber = z.string().or(z.number())

// Discriminated union (better performance)
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('scroll'), direction: z.enum(['up', 'down']) }),
])

type Event = z.infer<typeof EventSchema>
// | { type: 'click', x: number, y: number }
// | { type: 'keypress', key: string }
// | { type: 'scroll', direction: 'up' | 'down' }

Nullable and Optional

Code
TypeScript
// Optional - can be undefined
const OptionalSchema = z.string().optional()
type Optional = z.infer<typeof OptionalSchema> // string | undefined

// Nullable - can be null
const NullableSchema = z.string().nullable()
type Nullable = z.infer<typeof NullableSchema> // string | null

// Nullish - can be null or undefined
const NullishSchema = z.string().nullish()
type Nullish = z.infer<typeof NullishSchema> // string | null | undefined

// Default - default value if undefined
const DefaultSchema = z.string().default('default value')
const WithDefault = z.object({
  name: z.string(),
  role: z.string().default('user'),
})

// Catch - fallback value if validation fails
const CatchSchema = z.string().catch('fallback')

Validation and parsing

safeParse vs parse

Code
TypeScript
const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})

// parse - throws an error if invalid
try {
  const user = UserSchema.parse({
    name: 'John',
    email: 'invalid-email',
  })
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues)
  }
}

// safeParse - returns a result object (recommended!)
const result = UserSchema.safeParse({
  name: 'John',
  email: 'john@example.com',
})

if (result.success) {
  console.log(result.data) // typed!
  // { name: string, email: string }
} else {
  console.log(result.error.issues)
  // [{ code: 'invalid_string', message: 'Invalid email', path: ['email'] }]
}

// parseAsync - for schemas with async refinements
const asyncResult = await UserSchema.safeParseAsync(data)

Error handling

Code
TypeScript
const result = UserSchema.safeParse(invalidData)

if (!result.success) {
  // Raw issues
  console.log(result.error.issues)

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

  // Flattened errors (great for forms)
  const flattened = result.error.flatten()
  // {
  //   formErrors: [],
  //   fieldErrors: {
  //     name: ['Required'],
  //     email: ['Invalid email']
  //   }
  // }
}

Transformations

Code
TypeScript
// Transform - transform the value after validation
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10))
type StringToNumber = z.infer<typeof StringToNumberSchema> // number

// Transform with validation
const PositiveIntSchema = z.string()
  .transform((val) => parseInt(val, 10))
  .pipe(z.number().positive().int())

// Coerce - automatic type conversion
const CoercedStringSchema = z.coerce.string() // any -> string
const CoercedNumberSchema = z.coerce.number() // "42" -> 42
const CoercedBooleanSchema = z.coerce.boolean() // "true" -> true
const CoercedDateSchema = z.coerce.date() // "2024-01-01" -> Date

// Preprocess - transformation BEFORE validation
const TrimmedStringSchema = z.preprocess(
  (val) => (typeof val === 'string' ? val.trim() : val),
  z.string().min(1)
)

// Practical example: API input
const ApiInputSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  search: z.string().trim().optional(),
  sortBy: z.enum(['name', 'date', 'price']).default('date'),
  sortOrder: z.enum(['asc', 'desc']).default('desc'),
})

Refinements

Code
TypeScript
// Refine - custom validation
const PasswordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), {
    message: 'Requires an uppercase letter',
  })
  .refine((val) => /[0-9]/.test(val), {
    message: 'Requires a digit',
  })

// Superrefine - full control over errors
const FormSchema = 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 must be identical',
      path: ['confirmPassword'],
    })
  }
})

// Async refine (e.g., checking if email exists in the database)
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email)
    return !exists
  },
  { message: 'Email already exists' }
)

Integration with React Hook Form

Code
Bash
npm install react-hook-form @hookform/resolvers zod
Code
TypeScript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// Validation schema
const RegisterSchema = z.object({
  name: z.string().min(2, 'Minimum 2 characters'),
  email: z.string().email('Invalid email'),
  password: z.string()
    .min(8, 'Minimum 8 characters')
    .regex(/[A-Z]/, 'Requires an uppercase letter')
    .regex(/[0-9]/, 'Requires a digit'),
  confirmPassword: z.string(),
  terms: z.boolean().refine((val) => val === true, {
    message: 'You must accept the terms of service',
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must be identical',
  path: ['confirmPassword'],
})

type RegisterForm = z.infer<typeof RegisterSchema>

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegisterForm>({
    resolver: zodResolver(RegisterSchema),
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
      terms: false,
    },
  })

  const onSubmit = async (data: RegisterForm) => {
    console.log('Valid data:', data)
    await registerUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <span className="error">{errors.name.message}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <span className="error">{errors.password.message}</span>}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm password</label>
        <input id="confirmPassword" type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && (
          <span className="error">{errors.confirmPassword.message}</span>
        )}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register('terms')} />
          I accept the terms of service
        </label>
        {errors.terms && <span className="error">{errors.terms.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register'}
      </button>
    </form>
  )
}

Server Actions (Next.js)

TSapp/actions/posts.ts
TypeScript
// app/actions/posts.ts
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

const CreatePostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(100),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  tags: z.array(z.string()).min(1, 'Add at least one tag').max(5),
  published: z.boolean().default(false),
})

export type CreatePostInput = z.infer<typeof CreatePostSchema>

export type ActionResult = {
  success: boolean
  errors?: Record<string, string[]>
  data?: any
}

export async function createPost(formData: FormData): Promise<ActionResult> {
  // Extract data from FormData
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.getAll('tags'),
    published: formData.get('published') === 'true',
  }

  // Validate
  const result = CreatePostSchema.safeParse(rawData)

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

  // Save to database
  try {
    const post = await db.posts.create({
      data: result.data,
    })

    revalidatePath('/posts')

    return {
      success: true,
      data: post,
    }
  } catch (error) {
    return {
      success: false,
      errors: { _form: ['Error saving to database'] },
    }
  }
}
TSapp/posts/new/page.tsx
TypeScript
// app/posts/new/page.tsx
'use client'

import { useFormState } from 'react-dom'
import { createPost, type ActionResult } from '@/app/actions/posts'

const initialState: ActionResult = { success: false }

export default function NewPostPage() {
  const [state, formAction] = useFormState(createPost, initialState)

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" required />
        {state.errors?.title && (
          <span className="error">{state.errors.title[0]}</span>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" required />
        {state.errors?.content && (
          <span className="error">{state.errors.content[0]}</span>
        )}
      </div>

      <button type="submit">Publish</button>

      {state.success && <p className="success">Post has been created!</p>}
    </form>
  )
}

API Validation

TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { z } from 'zod'
import { NextResponse } from 'next/server'

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

export async function POST(request: Request) {
  try {
    const body = await request.json()

    const result = CreateUserSchema.safeParse(body)

    if (!result.success) {
      return NextResponse.json(
        {
          error: 'Validation failed',
          details: result.error.flatten().fieldErrors,
        },
        { status: 400 }
      )
    }

    const user = await db.users.create({
      data: result.data,
    })

    return NextResponse.json(user, { status: 201 })

  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

// Query params validation
const QuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  search: z.string().optional(),
})

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)

  const query = QuerySchema.safeParse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    search: searchParams.get('search'),
  })

  if (!query.success) {
    return NextResponse.json(
      { error: 'Invalid query parameters' },
      { status: 400 }
    )
  }

  const { page, limit, search } = query.data

  const users = await db.users.findMany({
    where: search ? { name: { contains: search } } : undefined,
    skip: (page - 1) * limit,
    take: limit,
  })

  return NextResponse.json({ users, page, limit })
}

Zod vs Alternatives

AspectZodYupJoi
TypeScriptFirst-classGoodPoor
Type inferenceAutomaticRequires typeNo
Bundle size~12kb~15kb~30kb
PerformanceVery goodGoodAverage
APIFunctionalChainableChainable
Async validationYesYesYes

Best practices

Do's

Code
TypeScript
// Use safeParse instead of parse
const result = schema.safeParse(data)

// Define schemas outside of components
const UserSchema = z.object({ ... })

// Use coerce for data from forms/URLs
z.coerce.number()

// Extract types from schemas
type User = z.infer<typeof UserSchema>

// Use custom error messages
z.string().min(1, 'Field is required')

Don'ts

Code
TypeScript
// Don't define schemas inside render
function Component() {
  const Schema = z.object({}) // Creates on every render
}

// Don't use parse without try-catch
const data = Schema.parse(input) // Can throw!

// Don't ignore the infer type
const Schema = z.object({ name: z.string() })
interface User { name: string } // Duplication

FAQ

Does Zod work with JavaScript (without TypeScript)?

Yes, but you lose the main advantage - automatic type inference.

How to validate files?

Code
TypeScript
const FileSchema = z.instanceof(File).refine(
  (file) => file.size <= 5 * 1024 * 1024,
  'Maximum size: 5MB'
)

How to do conditional validation?

Code
TypeScript
const Schema = z.object({
  type: z.enum(['email', 'phone']),
  value: z.string(),
}).refine(
  (data) => {
    if (data.type === 'email') {
      return z.string().email().safeParse(data.value).success
    }
    return z.string().regex(/^\d{9}$/).safeParse(data.value).success
  },
  { message: 'Invalid format', path: ['value'] }
)

Summary

Zod is a must-have for TypeScript projects:

  • Type inference - One schema, automatic type
  • Zero dependencies - Small bundle
  • Great API - Clear and functional
  • Integrations - React Hook Form, tRPC, Next.js