From Zero to AI

Lesson 5.3: Data Validation with Zod

Duration: 75 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Understand why runtime validation is essential
  2. Define schemas using Zod's type system
  3. Validate data and handle validation errors
  4. Infer TypeScript types from Zod schemas
  5. Create complex schemas with nested objects and arrays
  6. Transform and refine data during validation

Introduction

TypeScript types exist only at compile time. When data comes from external sources (APIs, user input, files), TypeScript cannot verify it matches your types. This is where runtime validation comes in.

// TypeScript CANNOT catch this at runtime
interface User {
  id: number;
  name: string;
}

const apiResponse = await fetch('/api/user/1');
const user = (await apiResponse.json()) as User;

// What if API returns { id: "1", name: null }?
// TypeScript won't complain, but your code will break!
console.log(user.name.toUpperCase()); // Runtime error: Cannot read property 'toUpperCase' of null

Zod solves this by validating data at runtime and generating TypeScript types from schemas.


Getting Started with Zod

Installation

npm install zod

Basic Usage

import { z } from 'zod';

// Define a schema
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Equivalent to: { id: number; name: string; email: string }

// Validate data
const validData = { id: 1, name: 'Alice', email: 'alice@example.com' };
const user = UserSchema.parse(validData); // Returns typed User

const invalidData = { id: '1', name: null, email: 'not-an-email' };
UserSchema.parse(invalidData); // Throws ZodError!

Primitive Types

Basic Primitives

import { z } from 'zod';

// String
const nameSchema = z.string();
nameSchema.parse('Alice'); // "Alice"
nameSchema.parse(123); // Throws error

// Number
const ageSchema = z.number();
ageSchema.parse(25); // 25
ageSchema.parse('25'); // Throws error

// Boolean
const activeSchema = z.boolean();
activeSchema.parse(true); // true
activeSchema.parse('yes'); // Throws error

// Null and Undefined
const nullSchema = z.null();
const undefinedSchema = z.undefined();

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

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

String Validations

const stringSchema = z
  .string()
  .min(1, 'Cannot be empty')
  .max(100, 'Too long')
  .email('Invalid email')
  .url('Invalid URL')
  .uuid('Invalid UUID')
  .regex(/^[A-Z]/, 'Must start with uppercase')
  .startsWith('Hello')
  .endsWith('!')
  .includes('world')
  .trim()
  .toLowerCase()
  .toUpperCase();

// Email validation
const emailSchema = z.string().email();
emailSchema.parse('alice@example.com'); // Valid
emailSchema.parse('not-an-email'); // Throws

// URL validation
const urlSchema = z.string().url();
urlSchema.parse('https://example.com'); // Valid
urlSchema.parse('not-a-url'); // Throws

Number Validations

const numberSchema = z
  .number()
  .min(0, 'Must be positive')
  .max(100, 'Must be 100 or less')
  .int('Must be an integer')
  .positive('Must be positive')
  .negative('Must be negative')
  .nonnegative('Must be >= 0')
  .nonpositive('Must be <= 0')
  .finite('Must be finite')
  .multipleOf(5, 'Must be multiple of 5');

// Age validation
const ageSchema = z.number().int().min(0).max(150);
ageSchema.parse(25); // Valid
ageSchema.parse(-5); // Throws
ageSchema.parse(200); // Throws

// Price validation (allow decimals)
const priceSchema = z.number().nonnegative();
priceSchema.parse(99.99); // Valid

Literal Types

// Exact values
const statusSchema = z.literal('active');
statusSchema.parse('active'); // Valid
statusSchema.parse('inactive'); // Throws

// Literal union (enum-like)
const roleSchema = z.union([z.literal('admin'), z.literal('user'), z.literal('guest')]);
// Or use z.enum
const roleSchema2 = z.enum(['admin', 'user', 'guest']);

type Role = z.infer<typeof roleSchema2>; // "admin" | "user" | "guest"

Objects and Arrays

Object Schemas

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(), // Optional field
  role: z.enum(['admin', 'user']).default('user'), // Default value
});

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

// Validation
const user = UserSchema.parse({
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
});
console.log(user.role); // "user" (default applied)

Nested Objects

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

const PersonSchema = z.object({
  name: z.string(),
  address: AddressSchema,
  workAddress: AddressSchema.optional(),
});

type Person = z.infer<typeof PersonSchema>;

const person = PersonSchema.parse({
  name: 'Alice',
  address: {
    street: '123 Main St',
    city: 'London',
    country: 'UK',
  },
});

Array Schemas

// Array of strings
const tagsSchema = z.array(z.string());
tagsSchema.parse(['a', 'b', 'c']); // Valid
tagsSchema.parse([1, 2, 3]); // Throws

// Array with constraints
const scoresSchema = z
  .array(z.number())
  .min(1, 'Need at least one score')
  .max(10, 'Maximum 10 scores')
  .nonempty('Cannot be empty');

// Array of objects
const UsersArraySchema = z.array(UserSchema);

const users = UsersArraySchema.parse([
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
]);

Tuples

// Fixed-length arrays with specific types per position
const coordinateSchema = z.tuple([z.number(), z.number()]);
coordinateSchema.parse([10, 20]); // Valid: [number, number]
coordinateSchema.parse([10, 20, 30]); // Throws (too many elements)

// Mixed types
const resultSchema = z.tuple([z.string(), z.number(), z.boolean()]);
resultSchema.parse(['success', 200, true]); // Valid

Optional, Nullable, and Defaults

Optional Fields

const schema = z.object({
  required: z.string(),
  optional: z.string().optional(), // string | undefined
});

schema.parse({ required: 'hello' }); // Valid
schema.parse({ required: 'hello', optional: 'world' }); // Valid
schema.parse({ required: 'hello', optional: undefined }); // Valid

Nullable Fields

const schema = z.object({
  name: z.string().nullable(), // string | null
});

schema.parse({ name: 'Alice' }); // Valid
schema.parse({ name: null }); // Valid
schema.parse({ name: undefined }); // Throws!

Nullable and Optional

const schema = z.object({
  // Can be string, null, or undefined (omitted)
  nickname: z.string().nullable().optional(),
});

schema.parse({}); // Valid
schema.parse({ nickname: null }); // Valid
schema.parse({ nickname: 'Ali' }); // Valid

Default Values

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

const config = ConfigSchema.parse({});
console.log(config);
// { host: "localhost", port: 3000, debug: false }

const customConfig = ConfigSchema.parse({ port: 8080 });
console.log(customConfig);
// { host: "localhost", port: 8080, debug: false }

Union and Discriminated Unions

Union Types

// Value can be string OR number
const stringOrNumber = z.union([z.string(), z.number()]);
stringOrNumber.parse('hello'); // Valid
stringOrNumber.parse(42); // Valid
stringOrNumber.parse(true); // Throws

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

Discriminated Unions

For objects that have a common "discriminator" field:

const DogSchema = z.object({
  type: z.literal('dog'),
  name: z.string(),
  breed: z.string(),
});

const CatSchema = z.object({
  type: z.literal('cat'),
  name: z.string(),
  indoor: z.boolean(),
});

const AnimalSchema = z.discriminatedUnion('type', [DogSchema, CatSchema]);

type Animal = z.infer<typeof AnimalSchema>;

// Usage
const dog = AnimalSchema.parse({
  type: 'dog',
  name: 'Rex',
  breed: 'German Shepherd',
});

const cat = AnimalSchema.parse({
  type: 'cat',
  name: 'Whiskers',
  indoor: true,
});

Parsing Methods

parse() - Throws on Error

const schema = z.string();

try {
  const result = schema.parse(123); // Throws ZodError
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.errors);
  }
}

safeParse() - Returns Result Object

const schema = z.string().email();

const result = schema.safeParse('not-an-email');

if (result.success) {
  console.log('Valid:', result.data);
} else {
  console.log('Errors:', result.error.errors);
}

parseAsync() - For Async Validation

const schema = z.string().refine(async (val) => {
  // Async validation (e.g., check database)
  return val !== 'taken';
}, 'Username is taken');

const result = await schema.parseAsync('newuser');

Error Handling

Accessing Error Details

const UserSchema = z.object({
  name: z.string().min(1, 'Name required'),
  email: z.string().email('Invalid email'),
  age: z.number().min(0, 'Age must be positive'),
});

const result = UserSchema.safeParse({
  name: '',
  email: 'bad-email',
  age: -5,
});

if (!result.success) {
  console.log(result.error.errors);
  // [
  //   { path: ["name"], message: "Name required", code: "too_small", ... },
  //   { path: ["email"], message: "Invalid email", code: "invalid_string", ... },
  //   { path: ["age"], message: "Age must be positive", code: "too_small", ... }
  // ]

  // Format errors by field
  const formatted = result.error.format();
  console.log(formatted.name?._errors); // ["Name required"]
  console.log(formatted.email?._errors); // ["Invalid email"]
}

Custom Error Messages

const schema = z.object({
  username: z
    .string({
      required_error: 'Username is required',
      invalid_type_error: 'Username must be a string',
    })
    .min(3, { message: 'Username must be at least 3 characters' }),

  age: z
    .number({
      required_error: 'Age is required',
    })
    .min(18, 'Must be 18 or older'),
});

Transformations

Transform Data During Validation

// String to number
const stringToNumber = z.string().transform((val) => parseInt(val, 10));
stringToNumber.parse('42'); // Returns number 42

// Transform object shape
const ApiUserSchema = z
  .object({
    user_id: z.number(),
    user_name: z.string(),
    email_address: z.string().email(),
  })
  .transform((user) => ({
    id: user.user_id,
    name: user.user_name,
    email: user.email_address,
  }));

type ApiUser = z.input<typeof ApiUserSchema>; // Input type (snake_case)
type User = z.output<typeof ApiUserSchema>; // Output type (camelCase)

const user = ApiUserSchema.parse({
  user_id: 1,
  user_name: 'Alice',
  email_address: 'alice@example.com',
});
// { id: 1, name: "Alice", email: "alice@example.com" }

Coercion

// Coerce to string (calls String() on input)
const coercedString = z.coerce.string();
coercedString.parse(123); // "123"
coercedString.parse(true); // "true"

// Coerce to number (calls Number() on input)
const coercedNumber = z.coerce.number();
coercedNumber.parse('42'); // 42
coercedNumber.parse('3.14'); // 3.14

// Coerce to boolean
const coercedBoolean = z.coerce.boolean();
coercedBoolean.parse('true'); // true (any truthy string)
coercedBoolean.parse(''); // false (empty string is falsy)
coercedBoolean.parse(1); // true

// Coerce to date
const coercedDate = z.coerce.date();
coercedDate.parse('2024-03-15'); // Date object
coercedDate.parse(1710460800000); // Date from timestamp

Refinements

Custom Validation Rules

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

// Using superRefine for multiple errors
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 do not match',
        path: ['confirmPassword'],
      });
    }
  });

Async Refinements

const usernameSchema = z
  .string()
  .min(3)
  .refine(async (username) => {
    // Simulate API call to check availability
    const isAvailable = await checkUsernameAvailable(username);
    return isAvailable;
  }, 'Username is already taken');

// Must use parseAsync
const result = await usernameSchema.parseAsync('newuser');

Practical Patterns

API Response Validation

// Define schema for API response
const PostSchema = z.object({
  id: z.number(),
  userId: z.number(),
  title: z.string(),
  body: z.string(),
});

const PostsResponseSchema = z.array(PostSchema);

type Post = z.infer<typeof PostSchema>;

// Fetch with validation
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const data = await response.json();

  // Validate and return typed data
  return PostsResponseSchema.parse(data);
}

// Safe version
async function fetchPostsSafe(): Promise<Post[] | null> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const data = await response.json();

  const result = PostsResponseSchema.safeParse(data);

  if (!result.success) {
    console.error('Invalid API response:', result.error);
    return null;
  }

  return result.data;
}

Form Validation

const RegistrationSchema = z
  .object({
    username: z
      .string()
      .min(3, 'Username must be at least 3 characters')
      .max(20, 'Username must be at most 20 characters')
      .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),

    email: z.string().email('Please enter a valid email'),

    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .regex(/[A-Z]/, 'Password must contain an uppercase letter')
      .regex(/[a-z]/, 'Password must contain a lowercase letter')
      .regex(/[0-9]/, 'Password must contain a number'),

    confirmPassword: z.string(),

    acceptTerms: z.literal(true, {
      message: 'You must accept the terms',
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

type RegistrationForm = z.infer<typeof RegistrationSchema>;

function validateForm(
  formData: unknown
): { valid: true; data: RegistrationForm } | { valid: false; errors: Record<string, string[]> } {
  const result = RegistrationSchema.safeParse(formData);

  if (result.success) {
    return { valid: true, data: result.data };
  }

  // Convert errors to field-based format
  const errors: Record<string, string[]> = {};

  for (const error of result.error.errors) {
    const field = error.path.join('.');
    if (!errors[field]) {
      errors[field] = [];
    }
    errors[field].push(error.message);
  }

  return { valid: false, errors };
}

Configuration Validation

const DatabaseConfigSchema = z.object({
  host: z.string().default('localhost'),
  port: z.number().int().min(1).max(65535).default(5432),
  database: z.string(),
  username: z.string(),
  password: z.string(),
  ssl: z.boolean().default(false),
  poolSize: z.number().int().min(1).max(100).default(10),
});

const AppConfigSchema = z.object({
  env: z.enum(['development', 'staging', 'production']),
  port: z.number().int().default(3000),
  database: DatabaseConfigSchema,
  apiKeys: z.record(z.string()), // Record<string, string>
});

type AppConfig = z.infer<typeof AppConfigSchema>;

function loadConfig(raw: unknown): AppConfig {
  return AppConfigSchema.parse(raw);
}

// Usage
const config = loadConfig({
  env: 'production',
  database: {
    host: 'db.example.com',
    database: 'myapp',
    username: 'admin',
    password: 'secret',
  },
  apiKeys: {
    stripe: 'sk_live_xxx',
    sendgrid: 'SG.xxx',
  },
});

Exercises

Exercise 1: User Profile Schema

Create a Zod schema for a user profile with the following requirements:

  • username: 3-20 characters, alphanumeric and underscores only
  • email: valid email format
  • age: optional, must be between 13 and 120
  • website: optional, must be valid URL
  • bio: optional, max 500 characters
// Your schema here
const UserProfileSchema = // ...

type UserProfile = z.infer<typeof UserProfileSchema>;
Solution
const UserProfileSchema = z.object({
  username: z
    .string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),

  email: z.string().email('Invalid email address'),

  age: z
    .number()
    .int('Age must be a whole number')
    .min(13, 'Must be at least 13 years old')
    .max(120, 'Invalid age')
    .optional(),

  website: z.string().url('Invalid URL').optional(),

  bio: z.string().max(500, 'Bio must be 500 characters or less').optional(),
});

type UserProfile = z.infer<typeof UserProfileSchema>;

// Test
const profile = UserProfileSchema.parse({
  username: 'alice_123',
  email: 'alice@example.com',
  age: 25,
  bio: 'Hello world!',
});

Exercise 2: API Response with Transform

Create a schema that validates this API response and transforms it to a cleaner format:

// API returns:
const apiResponse = {
  data: {
    user_id: 123,
    user_name: 'Alice Smith',
    created_at: '2024-01-15T10:30:00Z',
    is_active: true,
  },
  meta: {
    request_id: 'abc-123',
  },
};

// You want:
// {
//   id: 123,
//   name: "Alice Smith",
//   createdAt: Date,
//   isActive: true
// }
Solution
const ApiResponseSchema = z
  .object({
    data: z.object({
      user_id: z.number(),
      user_name: z.string(),
      created_at: z.string(),
      is_active: z.boolean(),
    }),
    meta: z.object({
      request_id: z.string(),
    }),
  })
  .transform((response) => ({
    id: response.data.user_id,
    name: response.data.user_name,
    createdAt: new Date(response.data.created_at),
    isActive: response.data.is_active,
  }));

type User = z.output<typeof ApiResponseSchema>;

// Test
const user = ApiResponseSchema.parse({
  data: {
    user_id: 123,
    user_name: 'Alice Smith',
    created_at: '2024-01-15T10:30:00Z',
    is_active: true,
  },
  meta: {
    request_id: 'abc-123',
  },
});

console.log(user);
// { id: 123, name: "Alice Smith", createdAt: Date, isActive: true }

Exercise 3: Discriminated Union

Create a schema for different types of notifications:

  • Email notification: has recipient (email) and subject
  • SMS notification: has phoneNumber and message
  • Push notification: has deviceToken and title and body

All have a common type field.

Solution
const EmailNotificationSchema = z.object({
  type: z.literal('email'),
  recipient: z.string().email(),
  subject: z.string().min(1),
});

const SMSNotificationSchema = z.object({
  type: z.literal('sms'),
  phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
  message: z.string().min(1).max(160),
});

const PushNotificationSchema = z.object({
  type: z.literal('push'),
  deviceToken: z.string().min(1),
  title: z.string().min(1),
  body: z.string(),
});

const NotificationSchema = z.discriminatedUnion('type', [
  EmailNotificationSchema,
  SMSNotificationSchema,
  PushNotificationSchema,
]);

type Notification = z.infer<typeof NotificationSchema>;

// Test
const email = NotificationSchema.parse({
  type: 'email',
  recipient: 'user@example.com',
  subject: 'Hello!',
});

const sms = NotificationSchema.parse({
  type: 'sms',
  phoneNumber: '+1234567890',
  message: 'Your code is 1234',
});

const push = NotificationSchema.parse({
  type: 'push',
  deviceToken: 'abc123',
  title: 'New message',
  body: 'You have a new message',
});

Exercise 4: Form Validation with Password Confirmation

Create a complete registration form schema with:

  • Username validation
  • Email validation
  • Password with strength requirements
  • Password confirmation that must match
  • Terms acceptance checkbox
Solution
const RegistrationFormSchema = z
  .object({
    username: z
      .string()
      .min(3, 'Username must be at least 3 characters')
      .max(30, 'Username must be at most 30 characters')
      .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, 'Username must start with a letter'),

    email: z.string().email('Please enter a valid email address'),

    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase letter')
      .refine((val) => /[a-z]/.test(val), 'Must contain lowercase letter')
      .refine((val) => /[0-9]/.test(val), 'Must contain a number')
      .refine((val) => /[^a-zA-Z0-9]/.test(val), 'Must contain a special character'),

    confirmPassword: z.string(),

    acceptTerms: z.boolean(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  })
  .refine((data) => data.acceptTerms === true, {
    message: 'You must accept the terms and conditions',
    path: ['acceptTerms'],
  });

type RegistrationForm = z.infer<typeof RegistrationFormSchema>;

// Test valid form
const validForm = RegistrationFormSchema.safeParse({
  username: 'alice123',
  email: 'alice@example.com',
  password: 'SecurePass1!',
  confirmPassword: 'SecurePass1!',
  acceptTerms: true,
});

console.log(validForm.success); // true

// Test invalid form
const invalidForm = RegistrationFormSchema.safeParse({
  username: 'al',
  email: 'not-an-email',
  password: 'weak',
  confirmPassword: 'different',
  acceptTerms: false,
});

if (!invalidForm.success) {
  console.log(invalidForm.error.errors);
}

Key Takeaways

  1. Runtime validation is essential for external data (APIs, user input)
  2. z.infer generates TypeScript types from schemas - single source of truth
  3. Use safeParse() for error handling without try/catch
  4. Coercion (z.coerce) handles type conversion automatically
  5. Transform changes data shape during validation
  6. Discriminated unions handle multiple object types elegantly
  7. Refinements add custom validation logic
  8. Zod schemas are composable - build complex schemas from simple ones

Resources

Resource Type Level
Zod Documentation Documentation Beginner
Zod GitHub Repository Beginner
Total TypeScript: Zod Tutorial Tutorial Intermediate
Zod Error Handling Documentation Intermediate

Next Lesson

Now that you can validate data, let us learn strategies for handling errors throughout your data processing pipeline.

Continue to Lesson 5.4: Error Handling Strategies