Sai Umesh

Runtime Type Safety in TypeScript with Zod

17 min read

TypeScript is one of my favorite programming languages. It gave JavaScript developers a way to write statically typed code with compile time type checking which is both its best and, sometimes, not so great feature. But wouldn’t it be awesome if we could have types that are not only checked at compile time but also at runtime? Enter Zod. I also recommend this awesome extension for VSCode for TypeScript pretty-ts-errors

I’ve been using Zod for a couple of years now, and it’s honestly one of the best things I’ve tried. It’s robust, easy to use, and works great for medium and large scale apps. Today, I’m going to show you a few of my favorite Zod features. Hope you find these examples helpful! And if you do, check out my other article on TypeScript.

A Simple Object

import z from 'zod'

const UserSchema = z.object({
  name: z.string()
})

type User = z.infer<typeof UserSchema>

// Property 'name' is missing in type '{}' but required in type '{ name: string; }'
const user: User = {}

Optional and Nullable Types

import { z } from 'zod'

// Define a schema with optional and nullable fields
const ProductSchema = z.object({
  id: z.string(),
  name: z.string(),
  description: z.string().optional(),
  stock: z.number().nullable()
})

// Sample input data
const product = {
  id: 'prod-1',
  name: 'Product 1',
  description: undefined,
  stock: null
}

// Validate at runtime
const parsedProduct = ProductSchema.safeParse(product)

if (!parsedProduct.success) {
  console.error('Invalid product data:', parsedProduct.error)
} else {
  console.log('Valid product data:', parsedProduct.data)
}

Array of objects

import z from 'zod'

const UserSchema = z.object({
  name: z.string(),
  location: z.string().optional()
})

const UsersSchema = z.array(UserSchema)

type Users = z.infer<typeof UsersSchema>

const user: Users = [
  {
    name: 'John',
    location: 'USA'
  },
  {
    name: 'Doe'
    // location is optional
  }
]

// Validate at runtime
const user = UserSchema.safeParse(inputData)

In-built validations such as email

import { z } from 'zod'

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

// Sample input data
const inputData = {
  name: 'John Doe',
  age: 30,
  email: 'john@example.com'
}

// Validate at runtime
const user = UserSchema.safeParse(inputData)

if (!user.success) {
  console.error('Invalid user data:', user.error)
} else {
  console.log('User data is valid:', user.data)
}

Runtime Enforced Arrays

import { z } from 'zod'

// Define a schema for an array of users
const UserArraySchema = z.array(
  z.object({
    name: z.string(),
    age: z.number()
  })
)

// Sample input data
const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 'not a number' } // Invalid age
]

// Validate at runtime
const parsedUsers = UserArraySchema.safeParse(users)

if (!parsedUsers.success) {
  console.error('Invalid user array:', parsedUsers.error)
} else {
  console.log('Valid user array:', parsedUsers.data)
}

Union Types

Zod can also handle union types, where a value can be one of several types. This is especially useful for situations like handling different response formats or inputs.

import { z } from 'zod'

// Define a schema for union types
const ResponseSchema = z.union([
  z.object({ success: z.literal(true), data: z.string() }),
  z.object({ success: z.literal(false), error: z.string() })
])

// Sample input data
const response = { success: true, data: 'All good!' }

// Validate at runtime
const parsedResponse = ResponseSchema.safeParse(response)

if (!parsedResponse.success) {
  console.error('Invalid response data:', parsedResponse.error)
} else {
  console.log('Valid response:', parsedResponse.data)
}

Preprocess for Pre-validation Data Transformation

import { z } from 'zod'

// Define a schema with preprocessing logic
const AgeSchema = z.preprocess((arg) => {
  if (typeof arg === 'string') {
    return parseInt(arg, 10) // Convert string to number
  }
  return arg
}, z.number().min(18, 'Must be at least 18'))

const inputData = { age: '21' }

// Validate at runtime
const result = AgeSchema.safeParse(inputData.age)

if (!result.success) {
  console.error(result.error.format())
} else {
  console.log('Valid age:', result.data)
}

default for Providing Default Values

import { z } from 'zod'

// Define a schema with default values
const UserSchema = z.object({
  username: z.string(),
  role: z.string().default('user') // Default value
})

// Sample input with missing role
const inputData = { username: 'john_doe' }

// Validate at runtime
const result = UserSchema.safeParse(inputData)

if (!result.success) {
  console.error(result.error.format())
} else {
  console.log('Valid user data:', result.data)
}

Custom Logic with refine

import { z } from 'zod'

// Define a schema with custom logic using refine
const AgeSchema = z.number().refine((age) => age >= 18, {
  message: 'Must be at least 18 years old'
})

// Test the schema
const result = AgeSchema.safeParse(16)

if (!result.success) {
  console.error(result.error)
} else {
  console.log('Valid age:', result.data)
}

Using Regex

import { z } from 'zod'

// Define a schema using regex to validate an email
const EmailSchema = z.string().regex(/^[w-.]+@([w-]+.)+[w-]{2,4}$/, {
  message: 'Invalid email address'
})

// Test the schema
const result = EmailSchema.safeParse('invalid-email')

if (!result.success) {
  console.error(result.error)
} else {
  console.log('Valid email:', result.data)
}

Advanced Custom Validation with superRefine

import { z } from 'zod'

// Define a schema for matching passwords
const PasswordSchema = 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'] // Points the error to the confirmPassword field
      })
    }
  })

// Test the schema
const result = PasswordSchema.safeParse({
  password: 'secret123',
  confirmPassword: 'secret321'
})

if (!result.success) {
  console.error(result.error)
} else {
  console.log('Passwords match:', result.data)
}

Validating an Array with Zod Enum from Array of Strings

import { z } from 'zod'

// Define allowed roles as an array of strings
const allowedRoles = ['admin', 'user', 'guest'] as const

// Create a Zod schema to validate an array of allowed roles
const UserRolesSchema = z.array(z.enum(allowedRoles))

// Test the schema with valid input
const validInput = UserRolesSchema.safeParse(['admin', 'user'])

if (!validInput.success) {
  console.error(validInput.error)
} else {
  console.log('Valid roles:', validInput.data)
}

// Test the schema with invalid input
const invalidInput = UserRolesSchema.safeParse(['admin', 'superadmin'])

if (!invalidInput.success) {
  console.error(invalidInput.error) // This will fail because "superadmin" is not in the allowed roles
} else {
  console.log('Valid roles:', invalidInput.data)
}

Zod Strict Mode

import { z } from 'zod'

// Define a schema for a user object with strict mode
const UserSchema = z
  .object({
    name: z.string(),
    age: z.number()
  })
  .strict()

// Test with valid input
const validInput = {
  name: 'John',
  age: 30
}

const result1 = UserSchema.safeParse(validInput)

if (!result1.success) {
  console.error(result1.error)
} else {
  console.log('Valid user data:', result1.data)
}

// Test with extra fields (will fail in strict mode)
const invalidInput = {
  name: 'Jane',
  age: 25,
  extraField: "I shouldn't be here" // Extra field that isn't allowed
}

const result2 = UserSchema.safeParse(invalidInput)

if (!result2.success) {
  console.error('Validation failed due to extra field:', result2.error)
} else {
  console.log('Valid user data:', result2.data)
}

Sai Umesh

I’m Sai Umesh, a software engineer based in India. Working as a DevOps engineer.