
When Your API Contract Becomes Your Type System
I've lost count of the times I've written an API endpoint, defined its types in TypeScript, and then duplicated that definition in a validation library. It's a waste of time and an open door for bugs. Zod fixes this by letting your validation schema *be* your type system.
by Sunil Band
Data Validation Should Be a Type Definition
We've all been there: you design an API, meticulously define its request and response shapes using TypeScript interfaces, and then immediately turn around and write runtime validation logic that essentially duplicates those types. You're using yup or Joi or validator.js, writing out string().min(3).max(255) when you already declared name: string; in your interface. This isn't just redundant; it's a maintenance nightmare. Changes in your TypeScript types don't automatically propagate to your validation, leading to silent bugs and unexpected runtime errors.
Then, on the client side, you fetch data, and your IDE tells you it's a User[]. But is it really? What if the API sent back malformed data? What if a string was actually a number? Your client-side TypeScript types are a contract with the server, but they're not enforced at runtime. This is where Zod shines: it lets you define a schema once, and that schema provides both runtime validation and static type inference. Your single source of truth becomes the validation schema itself.
The Problem with Manual Type-Validation Duplication
Let's consider a simple scenario: a user registration endpoint. You need to ensure the incoming request body has an email, password, and name, all with specific constraints. Without Zod, or a similar library, you'd likely end up with something like this:
// src/types/user.ts
export interface RegisterUserRequest {
email: string;
password: string;
name: string;
}
// src/server/validation.ts
import { Request, Response, NextFunction } from 'express';
export const validateRegistration = (req: Request, res: Response, next: NextFunction) => {
const { email, password, name } = req.body;
if (typeof email !== 'string' || !email.includes('@')) {
return res.status(400).json({ message: 'Invalid email' });
}
if (typeof password !== 'string' || password.length < 8) {
return res.status(400).json({ message: 'Password must be at least 8 characters' });
}
if (typeof name !== 'string' || name.length < 3) {
return res.status(400).json({ message: 'Name must be at least 3 characters' });
}
next();
};
// src/server/routes.ts
import express from 'express';
import { validateRegistration } from './validation';
import { RegisterUserRequest } from '../types/user';
const app = express();
app.use(express.json());
app.post('/register', validateRegistration, (req, res) => {
const user: RegisterUserRequest = req.body; // Trusting the middleware here
// ... save user to DB ...
res.status(201).json({ message: 'User registered', user: { email: user.email, name: user.name } });
});Notice the explicit RegisterUserRequest interface. Then, separate, entirely manual checks in validateRegistration. If you decide the name can be shorter, you update the interface, but you might forget name.length < 3. Or you remember, but now you've got two places to touch, which is boring and error-prone. This boilerplate quickly gets out of hand for complex objects, nested structures, or union types.
This pattern also leaves your client-side code vulnerable. When you make an API call, fetch('/api/users') might return any or rely on a User[] type that isn't enforced at runtime. If the backend sends bad data, your frontend might crash or display incorrect information without any type-checking catching it.
How Zod Simplifies Schema Definition
Zod's core idea is that your schema definition is your type definition. You define a Zod object, and Zod can infer the TypeScript type from it. This means one source of truth for both static type checking and runtime validation.
Let's refactor our user registration example using Zod:
// src/schemas/user.ts
import { z } from 'zod';
// Define the Zod schema for user registration
export const registerUserSchema = z.object({
email: z.string().email('Invalid email address'), // enforces string and email format
password: z.string().min(8, 'Password must be at least 8 characters long'), // enforces string and min length
name: z.string().min(3, 'Name must be at least 3 characters long'), // enforces string and min length
});
// Infer the TypeScript type from the Zod schema
export type RegisterUserRequest = z.infer<typeof registerUserSchema>;
// src/server/routes.ts
import express from 'express';
import { registerUserSchema, RegisterUserRequest } from '../schemas/user';
const app = express();
app.use(express.json());
app.post('/register', (req, res) => {
try {
// Validate the request body against the schema
const user: RegisterUserRequest = registerUserSchema.parse(req.body);
// If validation passes, 'user' is now type-safe and validated
// ... save user to DB ...
res.status(201).json({ message: 'User registered', user: { email: user.email, name: user.name } });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
console.error(error);
return res.status(500).json({ message: 'Something went wrong' });
}
});See how the registerUserSchema itself now defines the shape and constraints? The RegisterUserRequest type is derived directly from this schema using z.infer. If I change name.min(3) to name.min(2), the RegisterUserRequest type automatically reflects that change, and the runtime validation enforces it. This eliminates the duplication entirely.
On the client, you can use the exact same schema to validate data coming from the server, or even to type your form inputs before submission.
// src/client/api.ts
import { registerUserSchema, RegisterUserRequest } from '../schemas/user';
async function registerUser(userData: RegisterUserRequest): Promise<void> {
// Client-side validation before sending, optional but good UX
try {
registerUserSchema.parse(userData);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Client-side validation failed:', error.errors);
throw new Error('Invalid input');
}
throw error;
}
const response = await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Registration failed');
}
console.log('User registered successfully!');
}
// Usage in a React component
// ...
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const email = (event.currentTarget.elements.namedItem('email') as HTMLInputElement).value;
const password = (event.currentTarget.elements.namedItem('password') as HTMLInputElement).value;
const name = (event.currentTarget.elements.namedItem('name') as HTMLInputElement).value;
try {
await registerUser({
email,
password,
name,
});
} catch (error) {
console.error(error.message);
}
};This ensures that data is valid before it even leaves the client, and then again when it arrives at the server. It's a robust approach to end-to-end type safety.
Advanced Zod Features for Real-World Scenarios
Zod is far more powerful than just basic strings and numbers. It supports almost every TypeScript construct you can imagine, making it incredibly flexible.
Unions and Literals
Suppose you have a status field that can only be one of a few predefined strings:
import { z } from 'zod';
const orderStatusSchema = z.object({
id: z.string().uuid(),
status: z.union([
z.literal('pending'),
z.literal('shipped'),
z.literal('delivered'),
z.literal('cancelled')
]),
// Shorthand for literal union: z.enum(['pending', 'shipped', ...])
// This infers the type as 'pending' | 'shipped' | ...
});
type OrderStatus = z.infer<typeof orderStatusSchema>;
// Example usage
orderStatusSchema.parse({ id: 'abc-123', status: 'shipped' }); // OK
// orderStatusSchema.parse({ id: 'abc-123', status: 'returned' }); // Throws ZodErrorThis automatically gives you type status: 'pending' | 'shipped' | 'delivered' | 'cancelled'; in TypeScript, enforced at runtime.
Transformations and Refinements
Sometimes you need to transform data or add custom validation logic that goes beyond Zod's built-in methods. Zod offers transform and refine for this.
Let's say a date comes in as a string, but you want to work with a Date object, and also ensure it's in the future:
import { z } from 'zod';
const eventSchema = z.object({
title: z.string().min(5),
eventDate: z.string().datetime() // Ensure it's a valid datetime string first
.transform((str) => new Date(str)) // Transform to Date object
.refine((date) => date > new Date(), 'Event date must be in the future'), // Custom validation
});
type Event = z.infer<typeof eventSchema>;
// Example usage
try {
const futureEvent = eventSchema.parse({
title: 'Team Meeting',
eventDate: '2024-12-31T10:00:00Z' // Assuming this is in the future
});
console.log(futureEvent.eventDate.getFullYear()); // Access as a Date object
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.errors);
}
}Here, z.string().datetime() first validates the string format. Then, transform converts it to a Date object, and the inferred type for eventDate becomes Date. Finally, refine adds a custom check to ensure the date is in the future, providing a specific error message if not.
Optional and Nullable Fields
Dealing with optional or nullable data is a breeze:
import { z } from 'zod';
const userProfileSchema = z.object({
username: z.string(),
bio: z.string().optional(), // bio?: string; (optional property, defaults to undefined)
avatarUrl: z.string().url().nullable(), // avatarUrl: string | null; (can be null, but not undefined)
emailVerified: z.boolean().default(false), // emailVerified?: boolean | undefined; (defaults to false if not provided)
});
type UserProfile = z.infer<typeof userProfileSchema>;
const profile1: UserProfile = userProfileSchema.parse({ username: 'sunilband' });
console.log(profile1.emailVerified); // false
console.log(profile1.bio); // undefined
const profile2: UserProfile = userProfileSchema.parse({ username: 'jane', avatarUrl: null });
console.log(profile2.avatarUrl); // nullZod handles the nuances of undefined vs null and default values, which directly inform your TypeScript types.
Recursive Schemas
For self-referencing data structures, like a tree or a nested comment system, Zod uses .lazy():
import { z } from 'zod';
interface Category {
name: string;
subcategories?: Category[];
}
const categorySchema: z.ZodSchema<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(categorySchema).optional(),
})
);
type CategoryTree = z.infer<typeof categorySchema>;
const tree: CategoryTree = categorySchema.parse({
name: 'Electronics',
subcategories: [
{ name: 'Phones' },
{ name: 'Laptops', subcategories: [{ name: 'Gaming Laptops' }] },
],
});
console.log(tree.subcategories?.[1].subcategories?.[0].name); // 'Gaming Laptops'This allows you to define complex, recursive types while maintaining full type safety and validation.
Trade-offs and Considerations
While Zod is incredibly powerful, it's not a silver bullet. The main trade-off is the learning curve and the verbosity for very simple types.
For a basic string or number, writing z.string() or z.number() is slightly more verbose than just string or number in an interface. However, this is a minor point, as soon as you add any validation constraint (min length, email format, etc.), Zod quickly becomes less verbose and more powerful than manual checks.
Another consideration is bundle size. Zod isn't tiny, but it's well-optimized. For a small project where you only need basic types and no validation, it might be overkill. But for any serious application with API interactions, forms, or data parsing, the benefits of unified type-validation far outweigh the minimal bundle size impact.
Finally, the error handling in Zod is robust but requires a try/catch block or using .safeParse() instead of .parse(). This is explicit and good practice, but it's an additional pattern to remember when integrating it into existing codebases.
Zod in Action: A Full-Stack Example
Let's put this into a more complete context. Imagine a small Next.js application where you have a form to create a new task.
// src/schemas/task.ts
import { z } from 'zod';
export const taskSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters long'),
description: z.string().optional(),
dueDate: z.string().datetime().transform(str => new Date(str)).optional(), // Optional Date object
priority: z.enum(['low', 'medium', 'high']).default('medium'),
});
export type Task = z.infer<typeof taskSchema>;
export const createTaskSchema = taskSchema.omit({ priority: true }); // Example: priority is set on backend
export type CreateTaskPayload = z.infer<typeof createTaskSchema>;
// pages/api/tasks.ts (Next.js API Route)
import { NextApiRequest, NextApiResponse } from 'next';
import { createTaskSchema, Task } from '../../src/schemas/task';
export default function handler(req: NextApiRequest, res: NextApiResponse<Task | { errors: any[] }>) {
if (req.method === 'POST') {
try {
// Validate and infer type for the incoming request body
const newTaskData: Task = {
...createTaskSchema.parse(req.body),
priority: 'medium', // Backend sets default priority for new tasks
};
// In a real app: save to database
// const savedTask = await db.tasks.create(newTaskData);
const savedTask = { id: Math.random().toString(36).substring(7), ...newTaskData }; // Mock save
res.status(201).json(savedTask);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
console.error(error);
res.status(500).end();
}
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
// components/TaskForm.tsx (React Component)
'use client'; // For Next.js App Router, if applicable
import React, { useState } from 'react';
import { createTaskSchema, CreateTaskPayload } from '../src/schemas/task';
export function TaskForm() {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [dueDate, setDueDate] = useState('');
const [errors, setErrors] = useState<z.ZodIssue[]>([]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors([]);
const payload: CreateTaskPayload = {
title,
description: description || undefined, // Zod treats empty string as valid string, convert to undefined for optional
dueDate: dueDate || undefined,
};
try {
// Client-side validation using the same schema
createTaskSchema.parse(payload);
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json();
if (errorData.errors) {
setErrors(errorData.errors);
} else {
console.error('Server error:', errorData);
}
return;
}
const newTask = await res.json();
console.log('Task created:', newTask);
// Reset form or navigate
setTitle('');
setDescription('');
setDueDate('');
} catch (error) {
if (error instanceof z.ZodError) {
setErrors(error.errors);
} else {
console.error('An unexpected error occurred:', error);
}
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-md shadow-sm">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.find(err => err.path[0] === 'title') && (
<p className="text-red-500 text-sm mt-1">{errors.find(err => err.path[0] === 'title')?.message}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.find(err => err.path[0] === 'description') && (
<p className="text-red-500 text-sm mt-1">{errors.find(err => err.path[0] === 'description')?.message}</p>
)}
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium text-gray-700">Due Date (e.g., 2024-12-31T23:59:59Z)</label>
<input
type="datetime-local"
id="dueDate"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
{errors.find(err => err.path[0] === 'dueDate') && (
<p className="text-red-500 text-sm mt-1">{errors.find(err => err.path[0] === 'dueDate')?.message}</p>
)}
</div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create Task
</button>
</form>
);
}This example showcases sharing the taskSchema between the frontend and backend. The createTaskSchema demonstrates how you can derive a slightly different schema for a specific use case (e.g., omitting fields that are set by the server). Client-side validation catches errors before a network request is even made, improving user experience, while server-side validation ensures data integrity even if the client is bypassed or compromised.
Wrapping up
Zod fundamentally changes the way I approach API design and data handling in TypeScript applications. It bridges the gap between static type checking and runtime validation, eliminating boilerplate and a whole class of common bugs. By making your validation schema the single source of truth for both types and constraints, you get robust data integrity and a vastly improved developer experience.
If you're still manually writing validation logic or duplicating types for API contracts, give Zod a try. Start by defining one of your simpler API request/response bodies with a Zod schema, then use z.infer to get the TypeScript type. You'll quickly see the power of having a truly unified type system.

When Your Typescript Needs Runtime Validation: Diving into Zod
TypeScript gives us amazing static type safety, but it often falls short when data comes from untrusted sources like API responses or user input. Zod closes this gap by providing powerful schema validation at runtime, with excellent TypeScript inference to keep your types in sync.

When Your Frontend Needs to Process Files, Not Just Upload Them: Diving into WebAssembly
Sending user-generated files to a backend for processing often feels like the default, but it's not always the best solution. We'll explore how WebAssembly lets us handle complex file operations directly in the browser, boosting privacy and performance.

When Your Next.js App Needs to Speak Every Language: Diving into next-intl
Internationalization often feels like an afterthought until your product manager drops the 'global market' bomb. Suddenly, every string, date, and currency needs to adapt. While Next.js provides some primitives, a robust i18n solution is crucial, and next-intl makes this surprisingly elegant.


















