React - Types and Validation with Zod & TypeScript

Hi! Now that we know how to create forms, let's talk about data validation


The Problem with Validation

TypeScript, it's good but

TypeScript protects you during development, but:

// ✅ TypeScript validates this at build time
interface User {
  name: string
  age: number
  email: string
}

function processUser(user: User) {
  // TypeScript knows that user.name is a string
  console.log(user.name.toUpperCase())
}

// ❌ But this can happen at runtime!
const userFromAPI = JSON.parse(response) // any 😱
processUser(userFromAPI) // BOOM if userFromAPI doesn't have the right structure!

The Dangers of Runtime

  • 🌐 External APIs: can return anything
  • 📝 User forms: unreliable data
  • 🏪 LocalStorage/SessionStorage: can be corrupted
  • 🔗 URL parameters: can be manipulated client-side

The Solution? Runtime Validation

We need to validate the data at runtime AND have the security of TypeScript types!


Zod - The Holy Grail! ⚡

Installation

npm install zod

Zod's Philosophy

  1. Schema-first: you define the schema once
  2. Type inference: TypeScript types generated automatically
  3. Runtime validation: validation at runtime
  4. Developer-friendly: clear error messages

First Example

import { z } from 'zod'

// 🔥 Define the schema once
const UserSchema = z.object({
  name: z.string().min(1, 'Name required'),
  age: z.number().min(0).max(120),
  email: z.string().email('Invalid email'),
})

// ✅ TypeScript type generated automatically!
type User = z.infer<typeof UserSchema>
// type User = { name: string; age: number; email: string }

// 🛡️ Runtime validation
function createUser(data: unknown): User {
  // This can throw a ZodError if invalid
  return UserSchema.parse(data)
}

// 🚀 Usage
try {
  const user = createUser({
    name: "Andy",
    age: 25,
    email: "andy@example.com"
  })
  console.log(user.name) // TypeScript knows it's a string!
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues) // Error details
  }
}

API Data Validation

Classic Problem

// ❌ Dangerous code that we see everywhere
async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const user = await response.json() // any - DANGER!
  
  // What happens if the API returns something else?
  return user.name.toUpperCase() // CAN CRASH!
}

Zod Solution

import { z } from 'zod'

// Validation schema
const UserApiSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  avatar: z.string().url().optional(),
  createdAt: z.string().datetime(),
  settings: z.object({
    theme: z.enum(['light', 'dark']),
    notifications: z.boolean()
  })
})

type UserApi = z.infer<typeof UserApiSchema>

async function getUser(id: string): Promise<UserApi> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) throw new Error('API Error')
    
    const data = await response.json()
    
    // 🛡️ API data validation
    const user = UserApiSchema.parse(data)
    
    return user // 100% safe now!
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Invalid API response:', error.issues)
    }
    throw error
  }
}

// 🚀 Safe usage
const user = await getUser('123')
console.log(user.name.toUpperCase()) // ✅ Safe!
console.log(user.settings.theme) // ✅ Perfect TypeScript autocomplete

Advanced Form Validation

Complex Schemas

const ContactFormSchema = z.object({
  // Advanced string validation
  name: z.string()
    .min(1, 'Name required')
    .min(2, 'At least 2 characters')
    .max(50, 'Max 50 characters')
    .regex(/^[a-zA-ZÀ-ÿ\s]+$/, 'Invalid characters'),
  
  // Email with domain whitelist
  email: z.string()
    .email('Invalid email format')
    .refine(
      email => ['gmail.com', 'outlook.com', 'company.com'].includes(email.split('@')[1]),
      'Unauthorized email domain'
    ),
  
  // French phone number
  phone: z.string()
    .regex(/^(?:(?:\+33|0)[1-9](?:[0-9]{8}))$/, 'Invalid French number')
    .optional(),
  
  // Age with business validation
  age: z.number()
    .min(16, 'You must be at least 16 years old')
    .max(100, 'Invalid age'),
  
  // Password with security criteria
  password: z.string()
    .min(8, 'At least 8 characters')
    .regex(/[A-Z]/, 'At least one uppercase letter')
    .regex(/[a-z]/, 'At least one lowercase letter')
    .regex(/[0-9]/, 'At least one digit')
    .regex(/[^A-Za-z0-9]/, 'At least one special character'),
  
  // Password confirmation
  confirmPassword: z.string(),
  
  // Terms acceptance
  acceptTerms: z.boolean().refine(val => val === true, 'Terms required'),
  
  // Array of hobbies with validation
  hobbies: z.array(
    z.object({
      name: z.string().min(1),
      level: z.enum(['beginner', 'intermediate', 'expert'])
    })
  ).min(1, 'At least one hobby').max(5, 'Max 5 hobbies')
})
  // Cross-field validation
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'] // Error on the confirmPassword field
  })

type ContactFormData = z.infer<typeof ContactFormSchema>

API Routes Validation (Next.js)

Server-Side Validation

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

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().min(16).max(100)
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    
    // 🛡️ Validation of received data
    const userData = CreateUserSchema.parse(body)
    
    // Validated data, we can use it safely
    const user = await createUserInDatabase(userData)
    
    return NextResponse.json({ success: true, user })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        {
          error: 'Validation failed',
          details: error.issues
        },
        { status: 400 }
      )
    }
    
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Advanced Techniques

Conditional Schemas

const UserSchema = z.object({
  type: z.enum(['admin', 'user']),
  name: z.string(),
  email: z.string().email()
})
  .and(
    z.discriminatedUnion('type', [
      z.object({
        type: z.literal('admin'),
        permissions: z.array(z.string()).min(1)
      }),
      z.object({
        type: z.literal('user'),
        department: z.string()
      })
    ])
  )

Data Transformation

const DateSchema = z.string()
  .datetime()
  .transform(str => new Date(str))

const PriceSchema = z.string()
  .regex(/^\d+(\.\d{2})?$/)
  .transform(str => parseFloat(str))

const ProductSchema = z.object({
  name: z.string(),
  price: PriceSchema, // string → number
  createdAt: DateSchema // string → Date
})

Resources For Further Learning