Skip to main content

πŸ›‘οΈ Lesson 7.3: Form Validation with Zod

In the previous lesson, you learned React Hook Form and saw how it simplifies form handling. But you still wrote validation rules inline with each fieldβ€”required here, pattern there, custom validators everywhere. What if you could define your entire form's validation schema in one place, get automatic TypeScript types, and write validation that's reusable, composable, and incredibly powerful? That's exactly what Zod gives you. In this lesson, you'll learn how to combine React Hook Form with Zod to create bulletproof, type-safe forms with schema-based validation.

🎯 Learning Objectives

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

  • Understand what Zod is and why schema validation is powerful
  • Install and configure Zod with React Hook Form
  • Define validation schemas with Zod
  • Integrate Zod schemas with React Hook Form using resolvers
  • Create reusable validation schemas
  • Use advanced Zod features (transforms, refinements, custom errors)
  • Get automatic TypeScript type inference from schemas
  • Build complex nested schemas for real-world forms

Estimated Time: 60-75 minutes

Project: Build a complete registration form with Zod validation

πŸ“‘ In This Lesson

πŸ€” Why Zod?

In Lesson 7.2, you learned React Hook Form and wrote validation like this:

// React Hook Form validation (from Lesson 7.2)
<input
  {...register('email', {
    required: 'Email is required',
    pattern: {
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: 'Invalid email format'
    },
    minLength: {
      value: 5,
      message: 'Email must be at least 5 characters'
    }
  })}
/>

<input
  {...register('password', {
    required: 'Password is required',
    minLength: { value: 8, message: 'At least 8 characters' },
    validate: {
      hasUpper: v => /[A-Z]/.test(v) || 'Need uppercase',
      hasLower: v => /[a-z]/.test(v) || 'Need lowercase',
      hasNumber: v => /\d/.test(v) || 'Need number'
    }
  })}
/>

❌ Problems with Inline Validation

  • Repetitive - Copy-paste validation across forms
  • Hard to maintain - Update validation in multiple places
  • No reusability - Can't share validation logic
  • Manual types - Define TypeScript types separately
  • Scattered logic - Validation rules mixed with UI code
  • Limited composition - Hard to combine validations
  • Runtime-only - No validation at build time

Enter Zod: Schema-Based Validation

Now see the same form with Zod:

// Zod schema - define once, use everywhere
import { z } from 'zod';

const formSchema = z.object({
  email: z.string()
    .min(1, 'Email is required')
    .email('Invalid email format')
    .min(5, 'Email must be at least 5 characters'),
  
  password: z.string()
    .min(1, 'Password is required')
    .min(8, 'At least 8 characters')
    .regex(/[A-Z]/, 'Need uppercase letter')
    .regex(/[a-z]/, 'Need lowercase letter')
    .regex(/\d/, 'Need number')
});

// TypeScript type automatically inferred!
type FormData = z.infer<typeof formSchema>;
// Result: { email: string; password: string }

// Use with React Hook Form (one line!)
const { register, handleSubmit } = useForm<FormData>({
  resolver: zodResolver(formSchema)
});

// Now just register without validation
<input {...register('email')} />
<input {...register('password')} />

βœ… Zod Benefits

  • Single source of truth - All validation in one schema
  • Reusable - Share schemas across forms and API validation
  • Composable - Build complex schemas from simple ones
  • Type inference - TypeScript types generated automatically
  • Runtime + build time - Validation at both stages
  • Chainable API - Beautiful, readable validation chains
  • Rich validation - 40+ built-in validators
  • Tiny bundle - Only 8KB minified + gzipped

What is Zod?

Zod is a TypeScript-first schema validation library. Think of it as a way to define the "shape" of your dataβ€”what fields exist, what types they are, and what rules they must followβ€”all in one place.

πŸ“– Key Concepts

  • Schema - A definition of what valid data looks like
  • Validation - Checking if data matches the schema
  • Parsing - Validating and transforming data
  • Type inference - Automatic TypeScript types from schemas
  • Resolver - Bridge between Zod and React Hook Form

Zod vs Other Validation Libraries

Feature Zod Yup Joi
TypeScript Native, first-class Via types package Limited support
Type inference Automatic βœ… Manual Manual
Bundle size 8KB 15KB Not browser-friendly
API style Chainable, modern Chainable Object-based
Learning curve Easy Easy Moderate
Popularity Growing fast πŸš€ Established Server-focused

How Zod Works with React Hook Form

Zod and React Hook Form work together beautifully:

graph LR A[1. Define Zod Schema] --> B[2. Infer TypeScript Type] B --> C[3. Pass to useForm with resolver] C --> D[4. Register inputs] D --> E[5. User submits form] E --> F[6. Zod validates data] F --> G{Valid?} G -->|Yes| H[Call onSubmit with typed data] G -->|No| I[Show Zod error messages] style A fill:#667eea,color:#fff style H fill:#48bb78,color:#fff style I fill:#f56565,color:#fff

πŸ’‘ The Power of Schemas

A schema is like a blueprint. Once you define it:

  • βœ… Use it for form validation
  • βœ… Use it for API request validation
  • βœ… Use it for API response validation
  • βœ… Share it between frontend and backend
  • βœ… Generate documentation from it
  • βœ… Get TypeScript types automatically

When to Use Zod

βœ… Perfect For:

  • Any form with more than basic validation
  • Forms where you need strong typing
  • Reusable validation across multiple forms
  • API request/response validation
  • Complex nested data structures
  • Projects already using TypeScript
  • When you want a single source of truth

❌ Maybe Overkill For:

  • Single-field forms (search boxes)
  • Prototypes where validation rules are still changing
  • Simple forms with only required/minLength validation
  • Projects not using TypeScript

πŸ“¦ Installation and Setup

Let's get Zod installed and integrated with React Hook Form.

Step 1: Install Zod

First, install Zod itself:

# Using npm
npm install zod

# Using yarn
yarn add zod

# Using pnpm
pnpm add zod

Step 2: Install the Resolver

To connect Zod with React Hook Form, you need the resolver package:

# Using npm
npm install @hookform/resolvers

# Using yarn
yarn add @hookform/resolvers

# Using pnpm
pnpm add @hookform/resolvers

πŸ’‘ What is a Resolver?

A resolver is a bridge that connects validation libraries (like Zod, Yup, Joi) to React Hook Form. It tells React Hook Form:

  • How to validate form data using your schema
  • How to format error messages
  • When to trigger validation

The @hookform/resolvers package includes resolvers for all major validation libraries.

Step 3: Import and Set Up

Here's the complete setup in a React component:

// 1. Import Zod
import { z } from 'zod';

// 2. Import React Hook Form
import { useForm } from 'react-hook-form';

// 3. Import the Zod resolver
import { zodResolver } from '@hookform/resolvers/zod';

// 4. Define your schema
const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
});

// 5. Infer the TypeScript type
type FormData = z.infer<typeof schema>;

// 6. Use in your component
function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<FormData>({
    resolver: zodResolver(schema) // Connect Zod to React Hook Form!
  });

  const onSubmit = (data: FormData) => {
    console.log(data); // Fully validated and typed!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

βœ… What Just Happened?

  1. Defined a Zod schema describing valid form data
  2. Inferred TypeScript type from the schema (no manual typing!)
  3. Connected schema to React Hook Form via zodResolver
  4. Registered inputs without inline validation rules
  5. Zod automatically validates and provides error messages

Minimal Working Example

Here's the absolute minimum to get started:

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

// Schema
const schema = z.object({
  name: z.string().min(1, 'Name is required')
});

// Type
type FormData = z.infer<typeof schema>;

function SimpleForm() {
  const { register, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema)
  });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input {...register('name')} />
      <button>Submit</button>
    </form>
  );
}

⚠️ Common Setup Mistakes

  • ❌ Forgetting to install @hookform/resolvers
  • ❌ Using zod resolver instead of zodResolver
  • ❌ Not passing the resolver to useForm
  • ❌ Defining the schema inside the component (will recreate on every render)

Always define schemas outside your component!

πŸ‹οΈ Exercise 1: Setup and First Form

Task: Set up Zod with React Hook Form and create a simple login form.

  1. Install zod and @hookform/resolvers
  2. Create a LoginForm component
  3. Define a schema with email and password fields
  4. Email must be a valid email
  5. Password must be at least 6 characters
  6. Show error messages below each field
  7. Log the data on successful submit
πŸ’‘ Hint

Import z from 'zod', use z.object() for the schema, z.string() for fields, and chain .email() and .min(). Use zodResolver when calling useForm.

βœ… Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// Define schema outside component
const loginSchema = z.object({
  email: z.string()
    .min(1, 'Email is required')
    .email('Invalid email address'),
  password: z.string()
    .min(1, 'Password is required')
    .min(6, 'Password must be at least 6 characters')
});

// Infer TypeScript type
type LoginFormData = z.infer<typeof loginSchema>;

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

  const onSubmit = (data: LoginFormData) => {
    console.log('Login data:', data);
    alert(`Logging in with email: ${data.email}`);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      style={{ maxWidth: '400px', margin: '2rem auto' }}
    >
      <h2>Login</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email')}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Password
        </label>
        <input
          id="password"
          type="password"
          {...register('password')}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.password && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.password.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        style={{
          width: '100%',
          padding: '0.75rem',
          background: '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer',
          fontSize: '1rem'
        }}
      >
        Login
      </button>
    </form>
  );
}

export default LoginForm;

πŸ“ Your First Zod Schema

Let's build schemas step-by-step to understand how Zod works.

Basic Object Schema

The most common schema is an object with fields:

import { z } from 'zod';

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

// The schema is now a validator!
// It can parse and validate data:

// βœ… Valid data
const validUser = {
  name: 'Alice',
  age: 30,
  email: 'alice@example.com'
};

const result = userSchema.parse(validUser);
console.log(result); // { name: 'Alice', age: 30, email: 'alice@example.com' }

// ❌ Invalid data
const invalidUser = {
  name: 'Bob',
  age: 'thirty', // Wrong type!
  email: 'bob@example.com'
};

try {
  userSchema.parse(invalidUser);
} catch (error) {
  console.error('Validation failed:', error);
  // ZodError with detailed error information
}

πŸ’‘ Key Zod Methods

  • .parse(data) - Validate and return data (throws error if invalid)
  • .safeParse(data) - Validate and return {success, data} or {success, error}
  • .parseAsync(data) - Async version of parse
  • .partial() - Make all fields optional
  • .required() - Make all fields required
  • .extend() - Add more fields to schema

Building a Schema Step by Step

Let's build a registration form schema progressively:

import { z } from 'zod';

// Start simple
const step1 = z.object({
  username: z.string(),
  email: z.string()
});

// Add validation rules
const step2 = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email()
});

// Add custom error messages
const step3 = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters'),
  email: z.string()
    .email('Invalid email address')
});

// Add more fields
const step4 = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters'),
  email: z.string()
    .email('Invalid email address'),
  password: z.string()
    .min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string()
});

// Add field relationships (password confirmation)
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'),
  email: z.string()
    .email('Invalid email address'),
  password: z.string()
    .min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'] // Error will show on confirmPassword field
});

// Infer the TypeScript type
type RegistrationData = z.infer<typeof registrationSchema>;
// Result: {
//   username: string;
//   email: string;
//   password: string;
//   confirmPassword: string;
// }

βœ… Schema Building Strategy

  1. Start with basic structure (field names and types)
  2. Add simple validation (min, max, email, etc.)
  3. Add custom error messages
  4. Add more fields as needed
  5. Add cross-field validation with refine()
  6. Test with sample data

Schema Composition

Build complex schemas by composing smaller ones:

import { z } from 'zod';

// Define reusable schemas
const addressSchema = z.object({
  street: z.string().min(1, 'Street is required'),
  city: z.string().min(1, 'City is required'),
  zipCode: z.string().regex(/^\d{5}$/, 'Zip code must be 5 digits'),
  country: z.string().min(1, 'Country is required')
});

const contactSchema = z.object({
  phone: z.string().regex(/^\d{10}$/, 'Phone must be 10 digits'),
  email: z.string().email('Invalid email')
});

// Compose them into a larger schema
const userProfileSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  age: z.number().min(18, 'Must be 18 or older'),
  address: addressSchema,     // Nested schema!
  contact: contactSchema,      // Another nested schema!
  bio: z.string().optional()   // Optional field
});

// Type inference works with nested schemas!
type UserProfile = z.infer<typeof userProfileSchema>;
// Result: {
//   name: string;
//   age: number;
//   address: {
//     street: string;
//     city: string;
//     zipCode: string;
//     country: string;
//   };
//   contact: {
//     phone: string;
//     email: string;
//   };
//   bio?: string | undefined;
// }

πŸ’‘ Benefits of Schema Composition

  • Reusability - Use addressSchema across multiple forms
  • Maintainability - Update address validation in one place
  • Clarity - Each schema has a single responsibility
  • Testing - Test schemas independently
  • Sharing - Share schemas between frontend and backend

Testing Schemas

Before using a schema in a form, test it with sample data:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email(),
  age: z.number().min(18)
});

// Test valid data
const validData = { email: 'test@example.com', age: 25 };
const result1 = userSchema.safeParse(validData);
console.log(result1);
// { success: true, data: { email: 'test@example.com', age: 25 } }

// Test invalid data
const invalidData = { email: 'not-an-email', age: 15 };
const result2 = userSchema.safeParse(invalidData);
console.log(result2);
// { success: false, error: ZodError { issues: [...] } }

if (!result2.success) {
  console.log('Validation errors:');
  result2.error.issues.forEach(issue => {
    console.log(`${issue.path.join('.')}: ${issue.message}`);
  });
}
// Output:
// email: Invalid email
// age: Number must be greater than or equal to 18

βœ… Best Practice: Test Your Schemas

Always test schemas with both valid and invalid data before using them in production:

  • Create a test file for your schemas
  • Test edge cases (empty strings, negative numbers, etc.)
  • Verify error messages are clear and helpful
  • Test nested schemas thoroughly
  • Use safeParse for testing (doesn't throw errors)

πŸ”— Integrating with React Hook Form

Now that you understand Zod schemas, let's see how to use them with React Hook Form in practice.

Complete Integration Example

Here's a full working example with all the pieces together:

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

// 1. Define the schema
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_]+$/, 'Username can only contain letters, numbers, and underscores'),
  
  email: z.string()
    .min(1, 'Email is required')
    .email('Invalid email address'),
  
  age: z.number({
    required_error: 'Age is required',
    invalid_type_error: 'Age must be a number'
  })
    .min(18, 'You must be at least 18 years old')
    .max(120, 'Please enter a valid age'),
  
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
    .regex(/[0-9]/, 'Password must contain at least one number'),
  
  confirmPassword: z.string(),
  
  terms: z.boolean()
    .refine(val => val === true, {
      message: 'You must accept the terms and conditions'
    })
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
});

// 2. Infer TypeScript type
type RegistrationFormData = z.infer;

// 3. Create the form component
function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    resolver: zodResolver(registrationSchema)
  });

  const onSubmit = async (data: RegistrationFormData) => {
    console.log('Form data:', data);
    // Data is already validated by Zod!
    await new Promise(resolve => setTimeout(resolve, 1000));
    alert('Registration successful!');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Create Account</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="username">Username *</label>
        <input
          id="username"
          {...register('username')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.username ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.username && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.username.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email">Email *</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="age">Age *</label>
        <input
          id="age"
          type="number"
          {...register('age', { valueAsNumber: true })}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.age && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.age.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="password">Password *</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.password && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.password.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="confirmPassword">Confirm Password *</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.confirmPassword ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.confirmPassword && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.confirmPassword.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <input type="checkbox" {...register('terms')} />
          I accept the terms and conditions *
        </label>
        {errors.terms && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.terms.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        style={{
          width: '100%',
          padding: '0.75rem',
          background: isSubmitting ? '#ddd' : '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: isSubmitting ? 'not-allowed' : 'pointer'
        }}
      >
        {isSubmitting ? 'Creating Account...' : 'Create Account'}
      </button>
    </form>
  );
}

export default RegistrationForm;

βœ… Key Integration Points

  • zodResolver(schema) - Connects Zod to React Hook Form
  • z.infer<typeof schema> - Automatic TypeScript types
  • {...register('fieldName')} - No inline validation needed!
  • valueAsNumber: true - Convert number inputs to numbers
  • errors.fieldName.message - Zod's custom error messages
  • Cross-field validation with refine() works automatically

Validation Modes with Zod

Control when Zod validates your form:

// Validate on submit only (default)
const { register } = useForm({
  resolver: zodResolver(schema),
  mode: 'onSubmit'
});

// Validate on blur (good UX)
const { register } = useForm({
  resolver: zodResolver(schema),
  mode: 'onBlur'
});

// Validate on change (real-time feedback)
const { register } = useForm({
  resolver: zodResolver(schema),
  mode: 'onChange'
});

// Validate on blur, then on change (best UX)
const { register } = useForm({
  resolver: zodResolver(schema),
  mode: 'onTouched'
});

πŸ’‘ Recommended Mode for Zod

Use mode: 'onTouched' for the best user experience with Zod:

  • User isn't bothered while typing
  • Validation happens after they leave a field
  • Subsequent changes show real-time feedback
  • Balances helpfulness with intrusiveness

Handling Number and Date Inputs

Special handling for non-string inputs:

const schema = z.object({
  age: z.number(),
  birthDate: z.date(),
  quantity: z.number().int().positive()
});

type FormData = z.infer;

function NumberDateForm() {
  const { register, handleSubmit } = useForm({
    resolver: zodResolver(schema)
  });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      {/* For number inputs, use valueAsNumber */}
      <input
        type="number"
        {...register('age', { valueAsNumber: true })}
      />
      
      {/* For date inputs, use valueAsDate */}
      <input
        type="date"
        {...register('birthDate', { valueAsDate: true })}
      />
      
      {/* Integer input */}
      <input
        type="number"
        {...register('quantity', { valueAsNumber: true })}
      />
      
      <button type="submit">Submit</button>
    </form>
  );
}

⚠️ Important: Type Coercion

HTML inputs always return strings by default. You must tell React Hook Form to convert them:

  • valueAsNumber: true - For number inputs
  • valueAsDate: true - For date inputs

Without these, Zod will receive strings and validation will fail!

πŸ‹οΈ Exercise 2: Complete Form Integration

Task: Build a profile form with Zod validation.

Requirements:

  • Fields: firstName, lastName, email, age, bio
  • firstName and lastName: required, 2-50 characters
  • Email: required, valid email format
  • Age: required, number between 18-120
  • Bio: optional, max 200 characters
  • Use mode: 'onTouched' for validation
  • Show all error messages
  • Infer TypeScript types from schema
πŸ’‘ Hint

Use z.object() with z.string() and z.number(). Chain .min() and .max() for validation. Use .optional() for bio. Don't forget valueAsNumber for age!

βœ… Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// Define schema
const profileSchema = z.object({
  firstName: z.string()
    .min(1, 'First name is required')
    .min(2, 'First name must be at least 2 characters')
    .max(50, 'First name cannot exceed 50 characters'),
  
  lastName: z.string()
    .min(1, 'Last name is required')
    .min(2, 'Last name must be at least 2 characters')
    .max(50, 'Last name cannot exceed 50 characters'),
  
  email: z.string()
    .min(1, 'Email is required')
    .email('Invalid email address'),
  
  age: z.number({
    required_error: 'Age is required',
    invalid_type_error: 'Age must be a number'
  })
    .min(18, 'You must be at least 18 years old')
    .max(120, 'Please enter a valid age'),
  
  bio: z.string()
    .max(200, 'Bio cannot exceed 200 characters')
    .optional()
});

// Infer TypeScript type
type ProfileFormData = z.infer;

function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm({
    resolver: zodResolver(profileSchema),
    mode: 'onTouched'
  });

  const onSubmit = (data: ProfileFormData) => {
    console.log('Profile data:', data);
    alert('Profile updated successfully!');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Update Profile</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="firstName">First Name *</label>
        <input
          id="firstName"
          {...register('firstName')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.firstName ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.firstName && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.firstName.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="lastName">Last Name *</label>
        <input
          id="lastName"
          {...register('lastName')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.lastName ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.lastName && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.lastName.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email">Email *</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="age">Age *</label>
        <input
          id="age"
          type="number"
          {...register('age', { valueAsNumber: true })}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.age && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.age.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="bio">Bio (Optional)</label>
        <textarea
          id="bio"
          {...register('bio')}
          rows={4}
          placeholder="Tell us about yourself..."
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            fontFamily: 'inherit',
            border: `1px solid ${errors.bio ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.bio && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.bio.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        style={{
          width: '100%',
          padding: '0.75rem',
          background: '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Update Profile
      </button>
    </form>
  );
}

export default ProfileForm;

πŸ”€ Basic Zod Types

Zod provides types for all JavaScript primitives and more. Let's explore the fundamental building blocks.

Primitive Types

import { z } from 'zod';

// String
const stringSchema = z.string();
stringSchema.parse('hello'); // βœ… 'hello'
stringSchema.parse(123);     // ❌ Error

// Number
const numberSchema = z.number();
numberSchema.parse(42);      // βœ… 42
numberSchema.parse('42');    // ❌ Error

// Boolean
const booleanSchema = z.boolean();
booleanSchema.parse(true);   // βœ… true
booleanSchema.parse('true'); // ❌ Error

// BigInt
const bigintSchema = z.bigint();
bigintSchema.parse(9007199254740991n); // βœ…

// Date
const dateSchema = z.date();
dateSchema.parse(new Date()); // βœ…
dateSchema.parse('2024-01-01'); // ❌ Error

// Undefined
const undefinedSchema = z.undefined();
undefinedSchema.parse(undefined); // βœ…

// Null
const nullSchema = z.null();
nullSchema.parse(null); // βœ…

// Void (accepts undefined)
const voidSchema = z.void();
voidSchema.parse(undefined); // βœ…

String Schema

The string type is one of the most commonly used:

const schema = z.object({
  // Basic string
  name: z.string(),
  
  // Required string (not empty)
  username: z.string().min(1, 'Username is required'),
  
  // String with length constraints
  password: z.string()
    .min(8, 'At least 8 characters')
    .max(100, 'Maximum 100 characters'),
  
  // Email validation
  email: z.string().email('Invalid email'),
  
  // URL validation
  website: z.string().url('Invalid URL'),
  
  // UUID validation
  id: z.string().uuid('Invalid UUID'),
  
  // Custom regex
  phone: z.string().regex(/^\d{10}$/, 'Phone must be 10 digits'),
  
  // Specific format
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid zip code'),
  
  // Optional string
  bio: z.string().optional(),
  
  // String with default value
  role: z.string().default('user'),
  
  // Nullable string
  middleName: z.string().nullable()
});

Number Schema

const schema = z.object({
  // Basic number
  age: z.number(),
  
  // Positive number
  price: z.number().positive('Price must be positive'),
  
  // Non-negative (0 or greater)
  quantity: z.number().nonnegative('Cannot be negative'),
  
  // Negative number
  temperature: z.number().negative(),
  
  // Number with min/max
  rating: z.number()
    .min(1, 'Minimum rating is 1')
    .max(5, 'Maximum rating is 5'),
  
  // Integer only
  count: z.number().int('Must be an integer'),
  
  // Multiple of (divisible by)
  discount: z.number().multipleOf(5, 'Must be multiple of 5'),
  
  // Safe integer (JavaScript safe range)
  bigNumber: z.number().safe(),
  
  // Finite number (not Infinity or NaN)
  score: z.number().finite(),
  
  // Optional number
  optionalAge: z.number().optional(),
  
  // Number with default
  priority: z.number().default(0)
});

Boolean Schema

const schema = z.object({
  // Basic boolean
  isActive: z.boolean(),
  
  // Boolean that must be true (for terms acceptance)
  terms: z.boolean()
    .refine(val => val === true, {
      message: 'You must accept the terms'
    }),
  
  // Optional boolean
  newsletter: z.boolean().optional(),
  
  // Boolean with default
  emailNotifications: z.boolean().default(true)
});

Array Schema

// Array of strings
const tagsSchema = z.array(z.string());
tagsSchema.parse(['tag1', 'tag2']); // βœ…

// Array with constraints
const schema = z.object({
  // Array with min/max length
  tags: z.array(z.string())
    .min(1, 'At least one tag required')
    .max(5, 'Maximum 5 tags allowed'),
  
  // Array of numbers
  scores: z.array(z.number()),
  
  // Array of objects
  friends: z.array(z.object({
    name: z.string(),
    age: z.number()
  })),
  
  // Non-empty array
  categories: z.array(z.string()).nonempty('Select at least one category'),
  
  // Optional array
  hobbies: z.array(z.string()).optional(),
  
  // Array with default
  permissions: z.array(z.string()).default(['read'])
});

Object Schema

// Nested objects
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zipCode: z.string()
});

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  address: addressSchema, // Nested object
  
  // Object with dynamic keys
  metadata: z.record(z.string(), z.any()),
  
  // Optional object
  preferences: z.object({
    theme: z.string(),
    language: z.string()
  }).optional()
});

Enum Schema

// String enum
const roleSchema = z.enum(['admin', 'user', 'guest']);
roleSchema.parse('admin'); // βœ…
roleSchema.parse('superuser'); // ❌ Error

// In a form
const schema = z.object({
  role: z.enum(['admin', 'user', 'guest']),
  
  // With custom error
  status: z.enum(['active', 'inactive', 'pending'], {
    errorMap: () => ({ message: 'Invalid status' })
  }),
  
  // From TypeScript enum
  priority: z.nativeEnum(Priority) // where Priority is a TS enum
});

// TypeScript enum example
enum Priority {
  Low = 'low',
  Medium = 'medium',
  High = 'high'
}

const taskSchema = z.object({
  title: z.string(),
  priority: z.nativeEnum(Priority)
});

Union Types

// Union of primitives
const idSchema = z.union([z.string(), z.number()]);
idSchema.parse('123'); // βœ…
idSchema.parse(456);   // βœ…
idSchema.parse(true);  // ❌

// In a form
const schema = z.object({
  // Field can be string or number
  identifier: z.union([z.string(), z.number()]),
  
  // Contact preference
  contact: z.union([
    z.object({ type: z.literal('email'), email: z.string().email() }),
    z.object({ type: z.literal('phone'), phone: z.string() })
  ])
});

πŸ’‘ Type Reference Quick Guide

Type Zod Code TypeScript Type
String z.string() string
Number z.number() number
Boolean z.boolean() boolean
Array z.array(z.string()) string[]
Object z.object({...}) { ... }
Optional z.string().optional() string | undefined
Nullable z.string().nullable() string | null
Enum z.enum(['a', 'b']) 'a' | 'b'

Optional vs Nullable vs Default

Understanding the difference is crucial:

const schema = z.object({
  // Optional - field can be omitted or undefined
  bio: z.string().optional(),
  // Type: string | undefined
  // Valid: { bio: 'text' }, { bio: undefined }, {}
  
  // Nullable - field must exist but can be null
  middleName: z.string().nullable(),
  // Type: string | null
  // Valid: { middleName: 'Jane' }, { middleName: null }
  // Invalid: {} (field required)
  
  // Default - field uses default if omitted
  role: z.string().default('user'),
  // Type: string
  // Valid: { role: 'admin' }, {}
  // Result: { role: 'admin' }, { role: 'user' }
  
  // Optional + Nullable
  nickname: z.string().optional().nullable(),
  // Type: string | null | undefined
  // Valid: { nickname: 'Bob' }, { nickname: null }, { nickname: undefined }, {}
  
  // Nullable + Default
  theme: z.string().nullable().default('dark'),
  // Type: string | null
  // Valid: { theme: 'light' }, { theme: null }, {}
  // Result: { theme: 'light' }, { theme: null }, { theme: 'dark' }
});

βœ… When to Use Each

  • optional() - Field may or may not be in the data (form fields that can be empty)
  • nullable() - Field exists but value might be null (API fields that explicitly use null)
  • default() - Provide fallback value when field is missing (configuration options)

πŸ“ String Validation

Zod provides extensive string validation capabilities. Let's explore them in detail.

Length Validation

const schema = z.object({
  // Minimum length
  username: z.string().min(3, 'Username must be at least 3 characters'),
  
  // Maximum length
  bio: z.string().max(200, 'Bio cannot exceed 200 characters'),
  
  // Exact length
  zipCode: z.string().length(5, 'Zip code must be exactly 5 digits'),
  
  // Combined min and max
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .max(100, 'Password cannot exceed 100 characters'),
  
  // Non-empty (at least 1 character)
  name: z.string().min(1, 'Name is required')
});

Format Validation

const schema = z.object({
  // Email validation
  email: z.string().email('Invalid email address'),
  
  // URL validation
  website: z.string().url('Invalid URL'),
  
  // UUID validation
  id: z.string().uuid('Invalid UUID'),
  
  // CUID validation
  userId: z.string().cuid('Invalid CUID'),
  
  // CUID2 validation
  sessionId: z.string().cuid2('Invalid CUID2'),
  
  // ULID validation
  transactionId: z.string().ulid('Invalid ULID'),
  
  // ISO datetime validation
  createdAt: z.string().datetime('Invalid datetime'),
  
  // IP address validation
  ipAddress: z.string().ip('Invalid IP address'),
  
  // IPv4 specific
  ipv4: z.string().ip({ version: 'v4', message: 'Invalid IPv4' }),
  
  // IPv6 specific
  ipv6: z.string().ip({ version: 'v6', message: 'Invalid IPv6' })
});

Pattern Matching (Regex)

const schema = z.object({
  // Phone number (US format)
  phone: z.string().regex(
    /^\d{3}-\d{3}-\d{4}$/,
    'Phone must be in format: 123-456-7890'
  ),
  
  // Alphanumeric only
  username: z.string().regex(
    /^[a-zA-Z0-9]+$/,
    'Username can only contain letters and numbers'
  ),
  
  // Starts with capital letter
  name: z.string().regex(
    /^[A-Z]/,
    'Name must start with a capital letter'
  ),
  
  // Hex color code
  color: z.string().regex(
    /^#[0-9A-Fa-f]{6}$/,
    'Invalid hex color code'
  ),
  
  // Credit card number (simple check)
  creditCard: z.string().regex(
    /^\d{4}-\d{4}-\d{4}-\d{4}$/,
    'Credit card must be in format: 1234-5678-9012-3456'
  ),
  
  // Social Security Number
  ssn: z.string().regex(
    /^\d{3}-\d{2}-\d{4}$/,
    'SSN must be in format: 123-45-6789'
  )
});

String Transformations

const schema = z.object({
  // Trim whitespace
  name: z.string().trim(),
  
  // Convert to lowercase
  email: z.string().toLowerCase().email(),
  
  // Convert to uppercase
  code: z.string().toUpperCase(),
  
  // Chain transformations
  username: z.string()
    .trim()
    .toLowerCase()
    .min(3, 'At least 3 characters'),
  
  // Custom transformation
  slug: z.string().transform(val => 
    val.toLowerCase().replace(/\s+/g, '-')
  )
});

// Example usage
const result = schema.parse({
  name: '  John Doe  ',
  email: 'JOHN@EXAMPLE.COM',
  code: 'abc123',
  username: '  JohnDoe  ',
  slug: 'My Blog Post'
});

console.log(result);
// {
//   name: 'John Doe',
//   email: 'john@example.com',
//   code: 'ABC123',
//   username: 'johndoe',
//   slug: 'my-blog-post'
// }

βœ… String Validation Best Practices

  • Always trim strings to remove accidental whitespace
  • Use built-in validators (email, url) instead of custom regex when possible
  • Provide clear, specific error messages
  • Consider case sensitivity (toLowerCase for emails)
  • Validate format AND length (e.g., email + min length)
  • Use transformations to normalize data

Common String Validation Patterns

Here are ready-to-use validation patterns for common scenarios:

import { z } from 'zod';

// Common patterns
const commonPatterns = {
  // Username (alphanumeric + underscore, 3-20 chars)
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username cannot exceed 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
  
  // Password (strong - 8+ chars, uppercase, lowercase, number, special)
  strongPassword: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
    .regex(/[0-9]/, 'Password must contain at least one number')
    .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
  
  // US Phone number
  usPhone: z.string()
    .regex(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
  
  // US Zip code (5 or 9 digits)
  usZip: z.string()
    .regex(/^\d{5}(-\d{4})?$/, 'Invalid zip code'),
  
  // Credit card number (simple format check)
  creditCard: z.string()
    .regex(/^\d{4}-\d{4}-\d{4}-\d{4}$/, 'Invalid credit card format'),
  
  // URL slug (lowercase, hyphens, numbers)
  slug: z.string()
    .regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
  
  // Hex color code
  hexColor: z.string()
    .regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color (e.g., #FF5733)'),
  
  // ISO Date string
  isoDate: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
};

// Example usage in a form
const formSchema = z.object({
  username: commonPatterns.username,
  password: commonPatterns.strongPassword,
  phone: commonPatterns.usPhone,
  zipCode: commonPatterns.usZip
});
πŸ‹οΈ Exercise 3: String Validation Practice

Task: Create a contact form with advanced string validation.

Requirements:

  • Name: required, trimmed, 2-50 characters, starts with capital letter
  • Email: required, valid email format, lowercase
  • Phone: required, US format (123-456-7890)
  • Website: optional, valid URL
  • Message: required, 10-500 characters, trimmed
  • Use proper error messages for each validation
πŸ’‘ Hint

Chain validations: .trim().min().max().regex(). Use .toLowerCase() for email. Use .url() for website. Make website optional with .optional().

βœ… Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const contactSchema = z.object({
  name: z.string()
    .trim()
    .min(1, 'Name is required')
    .min(2, 'Name must be at least 2 characters')
    .max(50, 'Name cannot exceed 50 characters')
    .regex(/^[A-Z]/, 'Name must start with a capital letter'),
  
  email: z.string()
    .trim()
    .min(1, 'Email is required')
    .email('Invalid email address')
    .toLowerCase(),
  
  phone: z.string()
    .regex(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
  
  website: z.string()
    .url('Invalid URL')
    .optional()
    .or(z.literal('')), // Allow empty string
  
  message: z.string()
    .trim()
    .min(10, 'Message must be at least 10 characters')
    .max(500, 'Message cannot exceed 500 characters')
});

type ContactFormData = z.infer<typeof contactSchema>;

function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema)
  });

  const onSubmit = (data: ContactFormData) => {
    console.log('Contact form data:', data);
    alert('Message sent successfully!');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Contact Us</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="name">Name *</label>
        <input
          id="name"
          {...register('name')}
          placeholder="John Doe"
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.name && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.name.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email">Email *</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          placeholder="john@example.com"
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="phone">Phone *</label>
        <input
          id="phone"
          type="tel"
          {...register('phone')}
          placeholder="123-456-7890"
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.phone ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.phone && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.phone.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="website">Website (Optional)</label>
        <input
          id="website"
          type="url"
          {...register('website')}
          placeholder="https://example.com"
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.website ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.website && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.website.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="message">Message *</label>
        <textarea
          id="message"
          {...register('message')}
          rows={5}
          placeholder="Tell us what you need..."
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            fontFamily: 'inherit',
            border: `1px solid ${errors.message ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.message && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.message.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        style={{
          width: '100%',
          padding: '0.75rem',
          background: '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Send Message
      </button>
    </form>
  );
}

export default ContactForm;

πŸ”’ Number and Date Validation

Zod provides powerful validation for numbers and dates. Let's explore the options.

Number Validation Methods

import { z } from 'zod';

const schema = z.object({
  // Greater than (exclusive)
  age: z.number().gt(0, 'Age must be greater than 0'),
  
  // Greater than or equal (inclusive)
  rating: z.number().gte(1, 'Rating must be at least 1'),
  
  // Less than (exclusive)
  discount: z.number().lt(100, 'Discount must be less than 100'),
  
  // Less than or equal (inclusive)
  percentage: z.number().lte(100, 'Percentage cannot exceed 100'),
  
  // Positive (> 0)
  price: z.number().positive('Price must be positive'),
  
  // Non-negative (>= 0)
  quantity: z.number().nonnegative('Quantity cannot be negative'),
  
  // Negative (< 0)
  temperature: z.number().negative('Temperature must be negative'),
  
  // Non-positive (<= 0)
  debt: z.number().nonpositive('Debt cannot be positive'),
  
  // Multiple of (divisible by)
  stepValue: z.number().multipleOf(5, 'Must be a multiple of 5'),
  
  // Integer only
  count: z.number().int('Must be a whole number'),
  
  // Finite (not Infinity or NaN)
  score: z.number().finite('Must be a finite number'),
  
  // Safe integer (within JavaScript safe range)
  id: z.number().safe('Number too large')
});

Number Range Validation

const schema = z.object({
  // Between two values (inclusive)
  age: z.number()
    .min(18, 'Must be at least 18')
    .max(120, 'Must be at most 120'),
  
  // Rating system (1-5)
  rating: z.number()
    .int('Rating must be a whole number')
    .min(1, 'Minimum rating is 1')
    .max(5, 'Maximum rating is 5'),
  
  // Percentage (0-100)
  progress: z.number()
    .min(0, 'Cannot be negative')
    .max(100, 'Cannot exceed 100'),
  
  // Price (positive, max 2 decimals)
  price: z.number()
    .positive('Price must be positive')
    .multipleOf(0.01, 'Price can have at most 2 decimal places')
    .max(999999.99, 'Price is too high'),
  
  // Quantity (positive integer)
  quantity: z.number()
    .int('Quantity must be a whole number')
    .positive('Quantity must be positive')
    .max(9999, 'Quantity cannot exceed 9999')
});

Number with React Hook Form

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

const productSchema = z.object({
  name: z.string().min(1, 'Product name is required'),
  
  price: z.number({
    required_error: 'Price is required',
    invalid_type_error: 'Price must be a number'
  })
    .positive('Price must be positive')
    .multipleOf(0.01, 'Price can have at most 2 decimal places'),
  
  quantity: z.number({
    required_error: 'Quantity is required',
    invalid_type_error: 'Quantity must be a number'
  })
    .int('Quantity must be a whole number')
    .positive('Quantity must be positive'),
  
  discount: z.number()
    .min(0, 'Discount cannot be negative')
    .max(100, 'Discount cannot exceed 100%')
    .optional()
    .default(0)
});

type ProductFormData = z.infer<typeof productSchema>;

function ProductForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<ProductFormData>({
    resolver: zodResolver(productSchema)
  });

  const onSubmit = (data: ProductFormData) => {
    console.log('Product data:', data);
  };

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

      <div>
        <label>Price ($)</label>
        <input
          type="number"
          step="0.01"
          {...register('price', { valueAsNumber: true })}
        />
        {errors.price && <span>{errors.price.message}</span>}
      </div>

      <div>
        <label>Quantity</label>
        <input
          type="number"
          {...register('quantity', { valueAsNumber: true })}
        />
        {errors.quantity && <span>{errors.quantity.message}</span>}
      </div>

      <div>
        <label>Discount (%)</label>
        <input
          type="number"
          step="1"
          {...register('discount', { valueAsNumber: true })}
        />
        {errors.discount && <span>{errors.discount.message}</span>}
      </div>

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

⚠️ Critical: valueAsNumber

Always use valueAsNumber: true with number inputs:

// ❌ Wrong - Zod receives string "42"
<input type="number" {...register('age')} />

// βœ… Correct - Zod receives number 42
<input type="number" {...register('age', { valueAsNumber: true })} />

Without valueAsNumber, HTML inputs return strings and Zod validation will fail!

Date Validation

import { z } from 'zod';

const schema = z.object({
  // Basic date
  birthDate: z.date(),
  
  // Date with min (must be after)
  startDate: z.date()
    .min(new Date('2024-01-01'), 'Start date must be after Jan 1, 2024'),
  
  // Date with max (must be before)
  endDate: z.date()
    .max(new Date('2025-12-31'), 'End date must be before Dec 31, 2025'),
  
  // Date range
  eventDate: z.date()
    .min(new Date(), 'Event date must be in the future')
    .max(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), 'Event date must be within 1 year'),
  
  // Age validation (must be 18+ years old)
  dateOfBirth: z.date()
    .refine(date => {
      const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
      return age >= 18;
    }, {
      message: 'You must be at least 18 years old'
    })
});

Date with React Hook Form

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

const today = new Date();
today.setHours(0, 0, 0, 0);

const eventSchema = z.object({
  eventName: z.string().min(1, 'Event name is required'),
  
  startDate: z.date({
    required_error: 'Start date is required',
    invalid_type_error: 'Invalid date'
  })
    .min(today, 'Start date must be today or later'),
  
  endDate: z.date({
    required_error: 'End date is required',
    invalid_type_error: 'Invalid date'
  })
}).refine(data => data.endDate >= data.startDate, {
  message: 'End date must be after or equal to start date',
  path: ['endDate']
});

type EventFormData = z.infer<typeof eventSchema>;

function EventForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<EventFormData>({
    resolver: zodResolver(eventSchema)
  });

  const onSubmit = (data: EventFormData) => {
    console.log('Event data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Event Name</label>
        <input {...register('eventName')} />
        {errors.eventName && <span>{errors.eventName.message}</span>}
      </div>

      <div>
        <label>Start Date</label>
        <input
          type="date"
          {...register('startDate', { valueAsDate: true })}
        />
        {errors.startDate && <span>{errors.startDate.message}</span>}
      </div>

      <div>
        <label>End Date</label>
        <input
          type="date"
          {...register('endDate', { valueAsDate: true })}
        />
        {errors.endDate && <span>{errors.endDate.message}</span>}
      </div>

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

⚠️ Critical: valueAsDate

Always use valueAsDate: true with date inputs:

// ❌ Wrong - Zod receives string "2024-01-01"
<input type="date" {...register('birthDate')} />

// βœ… Correct - Zod receives Date object
<input type="date" {...register('birthDate', { valueAsDate: true })} />

Working with Date Strings

Sometimes you need to work with date strings instead of Date objects:

import { z } from 'zod';

// Validate ISO date string
const schema = z.object({
  // ISO 8601 datetime string
  createdAt: z.string().datetime(),
  
  // Custom date string format
  birthDate: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
    .refine(dateStr => {
      const date = new Date(dateStr);
      return !isNaN(date.getTime());
    }, {
      message: 'Invalid date'
    }),
  
  // Transform date string to Date object
  eventDate: z.string()
    .transform(str => new Date(str))
    .pipe(z.date().min(new Date(), 'Date must be in the future'))
});

Coercion for Flexibility

Use Zod's coerce feature to automatically convert types:

const schema = z.object({
  // Coerce string to number
  age: z.coerce.number()
    .int()
    .min(18, 'Must be at least 18'),
  
  // Coerce string to date
  birthDate: z.coerce.date()
    .max(new Date(), 'Birth date cannot be in the future'),
  
  // Coerce to boolean
  acceptTerms: z.coerce.boolean()
});

// Now you don't need valueAsNumber or valueAsDate!
function FlexibleForm() {
  const { register } = useForm({
    resolver: zodResolver(schema)
  });

  return (
    <form>
      {/* These work without valueAsNumber/valueAsDate */}
      <input type="number" {...register('age')} />
      <input type="date" {...register('birthDate')} />
      <input type="checkbox" {...register('acceptTerms')} />
    </form>
  );
}

πŸ’‘ When to Use Coercion

  • Use coerce - For API data or when types might vary
  • Use explicit types - For strict validation and type safety
  • Recommendation - Use explicit types with valueAsNumber/valueAsDate for forms (clearer intent)

πŸ”· TypeScript Type Inference

One of Zod's superpowers is automatic TypeScript type inference. Let's explore how it works.

Basic Type Inference

import { z } from 'zod';

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

// Infer the TypeScript type
type User = z.infer;

// Result is equivalent to:
// type User = {
//   name: string;
//   age: number;
//   email: string;
// }

// Use the inferred type
const user: User = {
  name: 'Alice',
  age: 30,
  email: 'alice@example.com'
};

Inference with Optional and Nullable

const schema = z.object({
  required: z.string(),
  optional: z.string().optional(),
  nullable: z.string().nullable(),
  withDefault: z.string().default('default'),
  optionalNullable: z.string().optional().nullable()
});

type InferredType = z.infer;

// Result:
// type InferredType = {
//   required: string;
//   optional?: string | undefined;
//   nullable: string | null;
//   withDefault: string;
//   optionalNullable?: string | null | undefined;
// }

Inference with Arrays and Objects

const schema = z.object({
  tags: z.array(z.string()),
  scores: z.array(z.number()),
  
  address: z.object({
    street: z.string(),
    city: z.string(),
    zipCode: z.string()
  }),
  
  contacts: z.array(z.object({
    name: z.string(),
    email: z.string().email()
  }))
});

type InferredType = z.infer;

// Result:
// type InferredType = {
//   tags: string[];
//   scores: number[];
//   address: {
//     street: string;
//     city: string;
//     zipCode: string;
//   };
//   contacts: Array<{
//     name: string;
//     email: string;
//   }>;
// }

Inference with Enums and Unions

const schema = z.object({
  role: z.enum(['admin', 'user', 'guest']),
  
  status: z.union([
    z.literal('active'),
    z.literal('inactive'),
    z.literal('pending')
  ]),
  
  identifier: z.union([z.string(), z.number()])
});

type InferredType = z.infer;

// Result:
// type InferredType = {
//   role: 'admin' | 'user' | 'guest';
//   status: 'active' | 'inactive' | 'pending';
//   identifier: string | number;
// }

Input vs Output Types

Zod schemas can have different input and output types (e.g., with transforms):

const schema = z.object({
  name: z.string().trim(), // Transform: trims whitespace
  email: z.string().toLowerCase(), // Transform: converts to lowercase
  age: z.string().transform(val => parseInt(val)) // Transform: string to number
});

// Input type (what goes in)
type Input = z.input;
// Result: { name: string; email: string; age: string; }

// Output type (what comes out after validation)
type Output = z.output;
// Result: { name: string; email: string; age: number; }

// z.infer is an alias for z.output
type InferredOutput = z.infer;
// Same as Output

Extracting Nested Types

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

const userSchema = z.object({
  name: z.string(),
  address: addressSchema
});

// Extract the entire user type
type User = z.infer;

// Extract just the address type
type Address = z.infer;

// Or extract from nested schema
type AddressFromUser = User['address'];

Reusing Schema Pieces

// Define reusable schemas
const emailSchema = z.string().email();
const passwordSchema = z.string().min(8);
const phoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/);

// Combine into different forms
const loginSchema = z.object({
  email: emailSchema,
  password: passwordSchema
});

const registrationSchema = z.object({
  email: emailSchema,
  password: passwordSchema,
  phone: phoneSchema,
  name: z.string()
});

// Both share the same email and password validation!
type LoginData = z.infer;
type RegistrationData = z.infer;

βœ… Type Inference Benefits

  • No duplicate definitions - Schema defines both validation and types
  • Always in sync - Change schema, types update automatically
  • Reduce errors - Impossible for types and validation to diverge
  • Better refactoring - TypeScript catches all affected code
  • Self-documenting - Schema shows exactly what's valid
  • Shared schemas - Use same schema for frontend and backend

Advanced Pattern: Extending Schemas

// Base schema
const baseUserSchema = z.object({
  name: z.string(),
  email: z.string().email()
});

// Extend with more fields
const adminSchema = baseUserSchema.extend({
  role: z.literal('admin'),
  permissions: z.array(z.string())
});

// Merge schemas
const userWithAddressSchema = baseUserSchema.merge(
  z.object({
    address: z.object({
      street: z.string(),
      city: z.string()
    })
  })
);

// Pick specific fields
const emailOnlySchema = baseUserSchema.pick({ email: true });

// Omit specific fields
const nameOnlySchema = baseUserSchema.omit({ email: true });

// Make all fields optional
const partialUserSchema = baseUserSchema.partial();

// Make all fields required
const requiredUserSchema = partialUserSchema.required();

// Infer types from all variants
type Admin = z.infer;
type UserWithAddress = z.infer;
type EmailOnly = z.infer;
type PartialUser = z.infer;

πŸ’‘ Schema Manipulation Methods

Method Purpose Example
.extend() Add new fields Add role to user
.merge() Combine schemas Merge user + address
.pick() Select specific fields Email-only form
.omit() Exclude fields Remove password field
.partial() Make all optional Update forms
.required() Make all required Strict validation

πŸ’¬ Custom Error Messages

Zod makes it easy to customize error messages for better user experience.

Per-Validation Messages

const schema = 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 allowed'),
  
  email: z.string()
    .min(1, 'Email is required')
    .email('Please enter a valid email address'),
  
  age: z.number({
    required_error: 'Age is required',
    invalid_type_error: 'Age must be a number'
  })
    .min(18, 'You must be at least 18 years old')
    .max(120, 'Please enter a valid age')
});

Custom Error Maps

Override default error messages globally:

import { z } from 'zod';

const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === 'string') {
      return { message: 'This field must be text' };
    }
    if (issue.expected === 'number') {
      return { message: 'This field must be a number' };
    }
  }
  
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === 'string') {
      return { message: `Minimum ${issue.minimum} characters required` };
    }
  }
  
  if (issue.code === z.ZodIssueCode.too_big) {
    if (issue.type === 'string') {
      return { message: `Maximum ${issue.maximum} characters allowed` };
    }
  }
  
  // Fallback to default message
  return { message: ctx.defaultError };
};

// Use custom error map
z.setErrorMap(customErrorMap);

// Or use per-schema
const schema = z.object({
  name: z.string().min(3)
}, { errorMap: customErrorMap });

Error Messages with Field Names

const createSchema = (fieldName: string) => {
  return z.string()
    .min(1, `${fieldName} is required`)
    .min(3, `${fieldName} must be at least 3 characters`);
};

const schema = z.object({
  firstName: createSchema('First name'),
  lastName: createSchema('Last name'),
  city: createSchema('City')
});

// Errors will be:
// "First name is required"
// "Last name must be at least 3 characters"
// "City is required"

Internationalization (i18n)

// Error messages in different languages
const errorMessages = {
  en: {
    required: 'This field is required',
    email: 'Invalid email address',
    minLength: (min: number) => `Minimum ${min} characters required`
  },
  es: {
    required: 'Este campo es obligatorio',
    email: 'DirecciΓ³n de correo electrΓ³nico no vΓ‘lida',
    minLength: (min: number) => `MΓ­nimo ${min} caracteres requeridos`
  }
};

const createLocalizedSchema = (lang: 'en' | 'es') => {
  const msg = errorMessages[lang];
  
  return z.object({
    email: z.string()
      .min(1, msg.required)
      .email(msg.email),
    
    password: z.string()
      .min(1, msg.required)
      .min(8, msg.minLength(8))
  });
};

// Use with current language
const schema = createLocalizedSchema('en'); // or 'es'

Contextual Error Messages

const schema = z.object({
  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')
    .regex(/[^A-Za-z0-9]/, 'Password must contain a special character'),
  
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'] // Show error on confirmPassword field
}).refine(data => data.password !== 'password123', {
  message: 'This password is too common. Please choose a different one.',
  path: ['password']
});

βœ… Error Message Best Practices

  • Be specific - "Email is required" not "Required"
  • Be helpful - Tell users how to fix the error
  • Be concise - Keep messages short and clear
  • Be polite - Use friendly, non-accusatory language
  • Show examples - "Format: 123-456-7890" for phone numbers
  • Use plain language - Avoid technical jargon
  • Be consistent - Use similar wording across forms

Error Message Examples

❌ Poor Error Messages

  • "Invalid" - What's invalid?
  • "Error in field" - Which field? What error?
  • "Must match pattern /^[A-Z]/" - Too technical
  • "Required" - Which field is required?

βœ… Good Error Messages

  • "Email address is required"
  • "Password must be at least 8 characters"
  • "Name must start with a capital letter"
  • "Phone number format: 123-456-7890"

πŸ“š Summary

Congratulations! You've mastered Zod and learned how to create powerful, type-safe forms. Let's recap what you've learned:

Key Takeaways

  • βœ… Schema-based validation - Define validation once, use everywhere
  • βœ… Type inference - Automatic TypeScript types from schemas
  • βœ… React Hook Form integration - Seamless with zodResolver
  • βœ… Rich validation - 40+ built-in validators for all types
  • βœ… Composable schemas - Build complex schemas from simple ones
  • βœ… Custom error messages - User-friendly, localized errors
  • βœ… Transformations - Clean and normalize data automatically
  • βœ… Cross-field validation - Validate fields against each other

Zod vs React Hook Form Inline Validation

Feature Inline Validation Zod + React Hook Form
Code location Mixed with UI Separate schema
Reusability Copy-paste Import and use
TypeScript types Manual definition Auto-inferred
Validation logic Per field, scattered Centralized schema
Cross-field validation Complex with watch() Simple with refine()
API validation Separate logic Same schema
Transformations Manual in submit Built into schema
Best for Simple forms Complex forms

Complete Example: Everything Together

Here's a comprehensive example showing all the concepts you've learned:

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

// 1. Reusable schema pieces
const emailSchema = z.string()
  .min(1, 'Email is required')
  .email('Invalid email address')
  .toLowerCase();

const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Must contain uppercase letter')
  .regex(/[a-z]/, 'Must contain lowercase letter')
  .regex(/[0-9]/, 'Must contain number')
  .regex(/[^A-Za-z0-9]/, 'Must contain special character');

// 2. Nested schemas
const addressSchema = z.object({
  street: z.string().min(1, 'Street is required'),
  city: z.string().min(1, 'City is required'),
  zipCode: z.string().regex(/^\d{5}$/, 'Invalid zip code'),
  country: z.enum(['US', 'CA', 'UK', 'AU'])
});

// 3. Main form schema with all features
const userRegistrationSchema = z.object({
  // Basic string validation
  firstName: z.string()
    .trim()
    .min(1, 'First name is required')
    .min(2, 'At least 2 characters')
    .max(50, 'Maximum 50 characters'),
  
  lastName: z.string()
    .trim()
    .min(1, 'Last name is required')
    .min(2, 'At least 2 characters')
    .max(50, 'Maximum 50 characters'),
  
  // Reusable schema
  email: emailSchema,
  password: passwordSchema,
  confirmPassword: z.string(),
  
  // Number validation
  age: z.number({
    required_error: 'Age is required',
    invalid_type_error: 'Age must be a number'
  })
    .int('Age must be a whole number')
    .min(18, 'Must be at least 18 years old')
    .max(120, 'Please enter a valid age'),
  
  // Date validation
  birthDate: z.date()
    .max(new Date(), 'Birth date cannot be in the future')
    .refine(date => {
      const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
      return age >= 18;
    }, 'Must be at least 18 years old'),
  
  // Nested schema
  address: addressSchema,
  
  // Array validation
  interests: z.array(z.string())
    .min(1, 'Select at least one interest')
    .max(5, 'Maximum 5 interests'),
  
  // Enum validation
  role: z.enum(['user', 'admin', 'moderator']),
  
  // Boolean validation
  terms: z.boolean()
    .refine(val => val === true, 'You must accept the terms'),
  
  // Optional fields
  bio: z.string()
    .max(500, 'Bio cannot exceed 500 characters')
    .optional(),
  
  // Field with default
  notifications: z.boolean().default(true)
})
// 4. Cross-field validation
.refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
});

// 5. Infer TypeScript type
type UserRegistrationData = z.infer;

// 6. Create form component
function UserRegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    resolver: zodResolver(userRegistrationSchema),
    mode: 'onTouched'
  });

  const onSubmit = async (data: UserRegistrationData) => {
    console.log('Validated data:', data);
    // Data is fully validated and typed!
    await new Promise(resolve => setTimeout(resolve, 1000));
    alert('Registration successful!');
  };

  return (
    
{/* All fields would go here */}
); }

βœ… Best Practices Checklist

  • βœ… Define schemas outside components (avoid recreation on render)
  • βœ… Use z.infer for automatic TypeScript types
  • βœ… Create reusable schema pieces for common patterns
  • βœ… Use meaningful, user-friendly error messages
  • βœ… Use transformations (trim, toLowerCase) to normalize data
  • βœ… Use mode: 'onTouched' for best UX
  • βœ… Use valueAsNumber for number inputs
  • βœ… Use valueAsDate for date inputs
  • βœ… Test schemas with sample data before using in production
  • βœ… Use refine() for cross-field validation
  • βœ… Compose complex schemas from simpler ones
  • βœ… Share schemas between frontend and backend

Common Patterns Quick Reference

// Required string
z.string().min(1, 'Required')

// Email
z.string().email('Invalid email')

// Password (strong)
z.string()
  .min(8)
  .regex(/[A-Z]/)
  .regex(/[a-z]/)
  .regex(/[0-9]/)

// Number range
z.number().min(1).max(100)

// Positive integer
z.number().int().positive()

// Date in future
z.date().min(new Date())

// Optional field
z.string().optional()

// Field with default
z.string().default('default')

// Enum
z.enum(['option1', 'option2'])

// Array with limits
z.array(z.string()).min(1).max(5)

// Nested object
z.object({
  nested: z.object({
    field: z.string()
  })
})

// Cross-field validation
schema.refine(data => data.password === data.confirm, {
  message: 'Must match',
  path: ['confirm']
})

// Transform
z.string().trim().toLowerCase()

// Coerce types
z.coerce.number()
z.coerce.date()

// Union
z.union([z.string(), z.number()])

// Regex validation
z.string().regex(/pattern/, 'Message')

When to Use Zod

βœ… Perfect For:

  • Forms with 5+ fields
  • Complex validation rules
  • Cross-field dependencies
  • TypeScript projects (type inference is amazing!)
  • Reusable validation across multiple forms
  • API request/response validation
  • Full-stack apps (share schemas between FE and BE)
  • When validation logic needs to be centralized
  • Production applications requiring maintainability

⚠️ Consider Simpler Solutions When:

  • Form has 1-3 fields with basic validation
  • Validation rules are extremely simple (just "required")
  • One-off form that won't be reused
  • Prototype where requirements are changing rapidly
  • Team unfamiliar with schema validation
  • Project not using TypeScript (lose main benefit)

Troubleshooting Common Issues

Problem: "Expected number, received string"

Solution: Use valueAsNumber: true on number inputs

<input type="number" {...register('age', { valueAsNumber: true })} />

Problem: "Expected date, received string"

Solution: Use valueAsDate: true on date inputs

<input type="date" {...register('date', { valueAsDate: true })} />

Problem: Schema recreated on every render

Solution: Define schema outside component

// ❌ Wrong - inside component
function MyForm() {
  const schema = z.object({ ... });
}

// βœ… Correct - outside component
const schema = z.object({ ... });
function MyForm() { ... }

Problem: Optional field shows "Required" error

Solution: Use .optional() or accept empty string

// For truly optional
bio: z.string().optional()

// For optional but allow empty string
website: z.string().url().optional().or(z.literal(''))

Problem: Cross-field validation error on wrong field

Solution: Specify path in refine

.refine(data => data.password === data.confirm, {
  message: 'Passwords must match',
  path: ['confirm'] // Show error on confirm field
})

🎯 What's Next?

In the upcoming lessons, you'll learn:

  • Lesson 7.4: File Uploads - Handle file inputs, image previews, and validation
  • Lesson 7.5: Advanced Form Patterns - Dynamic field arrays, multi-step wizards, conditional fields
  • Module Project - Build a complete user registration system with everything you've learned

You now have professional-grade form validation skills!

πŸ’ͺ You've Mastered

  • Schema-based validation with Zod
  • Seamless integration with React Hook Form
  • TypeScript type inference from schemas
  • All Zod primitive types (string, number, date, etc.)
  • Advanced string validation (email, URL, regex patterns)
  • Number and date validation with ranges
  • Optional, nullable, and default values
  • Cross-field validation with refine()
  • Schema composition and reusability
  • Custom error messages and localization
  • Data transformations and normalization
  • Schema manipulation (extend, merge, pick, omit)

You're now ready to build production-quality forms with confidence!

πŸ”₯ Pro Tips

  • Share schemas - Use the same Zod schemas on frontend and backend
  • Test schemas - Write unit tests for complex validation logic
  • Document schemas - Schemas are self-documenting but add comments for complex rules
  • Version schemas - When APIs evolve, version your schemas
  • Error messages matter - Invest time in clear, helpful error messages
  • Colocate schemas - Keep schemas near the forms that use them
  • Use TypeScript strict mode - Catch more errors at compile time
  • Leverage transformations - Clean data automatically (trim, lowercase)
  • Build a schema library - Create reusable schemas for your app
  • Monitor bundle size - Zod is small but tree-shake unused validators

πŸŽ‰ Congratulations!

You've completed Lesson 7.3: Form Validation with Zod

You now have the skills to build professional, type-safe forms that are maintainable, reusable, and user-friendly. Combined with React Hook Form, you have one of the most powerful form solutions available in the React ecosystem!