In the previous tutorial, we learned about testing TypeScript with Vitest. Now let’s learn about Zod — a library that validates data at runtime and generates TypeScript types from schemas.

By the end of this tutorial, you will know how to create Zod schemas, validate data, infer types, use transforms, and validate API requests and environment variables.

Why Do You Need Zod?

TypeScript types only exist at compile time. They disappear when your code runs. This means:

interface User {
  name: string;
  email: string;
  age: number;
}

// This compiles fine, but at runtime the data could be anything
const user: User = await response.json();
// What if the API returns { name: 123 }? No error at runtime!

Zod validates data at runtime and gives you TypeScript types automatically:

import { z } from "zod";

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

// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// Result: { name: string; email: string; age: number }

// Validate at runtime — throws if invalid
const user = UserSchema.parse(await response.json());

Installation

npm install zod

Zod is tested against TypeScript 5.5+ and requires strict mode in your tsconfig.json. Older TypeScript versions may work but are not officially supported.

Basic Schemas

Primitive Types

import { z } from "zod";

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

Validation

const nameSchema = z.string();

nameSchema.parse("Alex");   // Returns "Alex"
nameSchema.parse(123);      // Throws ZodError

Safe Parsing

safeParse returns a result object instead of throwing:

const result = nameSchema.safeParse("Alex");

if (result.success) {
  console.log(result.data); // "Alex" — typed as string
} else {
  console.log(result.error.issues); // Array of error details
}

This is similar to the Result type pattern from the error handling tutorial.

String Validations

const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();

const passwordSchema = z.string()
  .min(8, "Password must be at least 8 characters")
  .max(100, "Password must be at most 100 characters")
  .regex(/[A-Z]/, "Password must contain an uppercase letter")
  .regex(/[0-9]/, "Password must contain a number");

const trimmedSchema = z.string().trim(); // Trims whitespace before validation

Number Validations

const ageSchema = z.number()
  .int("Age must be a whole number")
  .min(0, "Age cannot be negative")
  .max(150, "Age cannot exceed 150");

const priceSchema = z.number()
  .positive("Price must be positive")
  .multipleOf(0.01); // Two decimal places

Object Schemas

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).optional(), // optional field
  role: z.enum(["admin", "editor", "viewer"]),
});

type User = z.infer<typeof UserSchema>;
// Result:
// {
//   id: number;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "editor" | "viewer";
// }

Nested Objects

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
  zip: z.string(),
});

const UserWithAddressSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  address: AddressSchema,
});

type UserWithAddress = z.infer<typeof UserWithAddressSchema>;

Partial and Required

Zod has its own partial() and required() methods:

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

const RequiredUser = UserSchema.required();
// All fields become required (removes optional)

// Make specific fields optional
const UpdateUser = UserSchema.partial().required({ id: true });
// id is required, everything else is optional

Pick and Omit

const UserPreview = UserSchema.pick({ id: true, name: true });
// Only id and name

const UserWithoutId = UserSchema.omit({ id: true });
// Everything except id

Array Schemas

const tagsSchema = z.array(z.string());
// string[]

const numbersSchema = z.array(z.number()).min(1).max(10);
// number[] with 1-10 items

const usersSchema = z.array(UserSchema);
// User[]

Union and Enum Schemas

Unions

const resultSchema = z.union([z.string(), z.number()]);
// string | number

// Shorthand
const idSchema = z.string().or(z.number());
// string | number

Discriminated Unions

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"), offset: z.number() }),
]);

type Event = z.infer<typeof EventSchema>;

Enums

const RoleSchema = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "editor" | "viewer"

// Access enum values
RoleSchema.enum.admin; // "admin"
RoleSchema.options;    // ["admin", "editor", "viewer"]

Transform and Refine

Transform — Change the Value

const numberFromString = z.string().transform(val => parseInt(val, 10));

numberFromString.parse("42"); // Returns 42 (number, not string)

type Result = z.infer<typeof numberFromString>; // number

Chaining Transforms

const slugSchema = z.string()
  .trim()
  .toLowerCase()
  .transform(val => val.replace(/\s+/g, "-"))
  .transform(val => val.replace(/[^a-z0-9-]/g, ""));

slugSchema.parse("  Hello World! "); // "hello-world"

Refine — Custom Validation

const passwordSchema = z.string()
  .min(8)
  .refine(val => /[A-Z]/.test(val), {
    message: "Must contain an uppercase letter",
  })
  .refine(val => /[0-9]/.test(val), {
    message: "Must contain a number",
  });

Superrefine — Multiple 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 do not match",
      path: ["confirmPassword"],
    });
  }
});

Default Values

const ConfigSchema = z.object({
  host: z.string().default("localhost"),
  port: z.number().default(3000),
  debug: z.boolean().default(false),
});

ConfigSchema.parse({}); // { host: "localhost", port: 3000, debug: false }
ConfigSchema.parse({ port: 8080 }); // { host: "localhost", port: 8080, debug: false }

Validating API Requests

Express Middleware

import { z } from "zod";
import { Request, Response, NextFunction } from "express";

function validate<T extends z.ZodType>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        errors: result.error.issues.map(i => ({
          path: i.path.join("."),
          message: i.message,
        })),
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

const CreateNoteSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).optional(),
});

app.post("/notes", validate(CreateNoteSchema), (req, res) => {
  // req.body is validated and typed
  const { title, content, tags } = req.body;
  res.status(201).json({ title, content, tags });
});

Validating Environment Variables

const EnvSchema = z.object({
  PORT: z.string().transform(Number).pipe(z.number().int().positive()),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

export const env = EnvSchema.parse(process.env);

// env.PORT is number (transformed from string)
// env.DATABASE_URL is string (validated as URL)
// env.NODE_ENV is "development" | "production" | "test"

If any variable is missing or invalid, Zod throws with a clear error message at startup.

Zod vs Other Libraries

FeatureZodYupJoi
TypeScript firstYesPartialNo
Type inferencez.infer<>LimitedNo
Zero dependenciesYesNoNo
Bundle size~13KB~25KB~150KB
EcosystemtRPC, React Hook FormFormikHapi

Zod is the best choice for TypeScript projects because of its first-class type inference.

What’s Next?

In the next tutorial, we will learn about tRPC — how to build end-to-end type-safe APIs using Zod for input validation.