
When Your API Contract Becomes Your Type System
Schema validation is often an afterthought, bolted on at the edge of your application. But what if your validation library could deeply integrate with TypeScript, generating types directly from your schemas? Zod changes the game by making your runtime validation the source of truth for your static t
by Sunil Band
The Problem with API Contracts
We've all been there: you design an API, define its shape in a backend/src/api-types.ts file, and then painstakingly replicate that structure in your frontend. Or worse, you just any your way through, hoping for the best. This duality leads to a frustrating cycle of bugs: a backend change breaks the frontend, a frontend developer assumes a field exists, and suddenly, you're debugging undefined is not a function at 3 AM.
The core issue is a mismatch between runtime validation and static type definitions. You'll often have a runtime check to ensure incoming data from a request body or a third-party API matches an expected shape. But this validation usually happens after the data has already been typed as any or some manually crafted interface. This means your TypeScript compiler isn't helping you enforce the contract where it matters most: at the boundary.
Enter Zod: Schema as Source of Truth
Zod flips this problem on its head. Instead of defining types and then writing validation, you define your validation schema, and Zod infers the TypeScript types from it. This means your runtime validation logic is your type definition. If your data passes validation, TypeScript knows its exact shape. No more manual type declarations for your API payloads, no more any, and a massive reduction in runtime type errors.
This approach brings incredible confidence. If your backend uses Zod to validate incoming requests, and your frontend uses the same Zod schemas to validate responses, you've established a single source of truth for your data contracts. Any deviation, either in runtime data or in how your code interacts with it, will be caught either at compile time by TypeScript or at runtime by Zod's validation.
A Practical Example: User Profile API
Let's walk through a common scenario: a user profile API. We'll define a schema for a user, use it for validation on a hypothetical backend, and then demonstrate how the inferred types make frontend development safer.
First, define your schema:
import { z } from 'zod';
// Define a schema for a user profile
export const UserProfileSchema = z.object({
id: z.string().uuid(), // Enforce UUID format for ID
username: z.string().min(3).max(20), // Username must be between 3 and 20 chars
email: z.string().email(), // Email must be a valid email format
age: z.number().int().positive().optional(), // Age is an optional positive integer
roles: z.array(z.enum(['admin', 'editor', 'viewer'])).default(['viewer']), // Array of predefined roles
createdAt: z.string().datetime(), // Timestamp string, will be parsed later
lastLoggedIn: z.string().datetime().nullable(), // Nullable datetime string
});
// Infer the TypeScript type directly from the schema
export type UserProfile = z.infer<typeof UserProfileSchema>;
// Let's say we have an update schema, where all fields are optional
export const UserProfileUpdateSchema = UserProfileSchema.partial();
export type UserProfileUpdate = z.infer<typeof UserProfileUpdateSchema>;
// A more complex example: a list of users, potentially with pagination metadata
export const PaginatedUsersSchema = z.object({
data: z.array(UserProfileSchema),
totalCount: z.number().int().nonnegative(),
currentPage: z.number().int().positive().default(1),
pageSize: z.number().int().positive().default(10),
});
export type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;Notice how we're defining constraints like min(3), email(), uuid(), and int().positive(). These aren't just type hints; they're runtime assertions. Zod handles the heavy lifting of ensuring data conforms to these rules.
Backend Usage
On the backend, let's imagine an Express endpoint that handles creating a new user. Instead of manually checking fields, we just parse the request body with our schema:
import express from 'express';
import { z } from 'zod';
import { UserProfileSchema } from './schemas'; // Assuming schemas.ts from above
const app = express();
app.use(express.json());
app.post('/users', (req, res) => {
try {
// Validate and parse the request body
const newUser = UserProfileSchema.parse({
...req.body,
id: 'some-generated-uuid',
createdAt: new Date().toISOString() // Backend-generated fields
});
// At this point, newUser is guaranteed to be of type UserProfile
// and all its properties conform to the schema rules.
console.log('New user created:', newUser);
// In a real app, you'd save newUser to a database
res.status(201).json(newUser);
} catch (error) {
if (error instanceof z.ZodError) {
// Zod provides detailed error messages
return res.status(400).json({ errors: error.errors });
}
res.status(500).json({ message: 'Internal server error' });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));The UserProfileSchema.parse() method is the key. If the req.body doesn't match the schema, it throws a ZodError with detailed information about what went wrong. Otherwise, newUser is statically typed as UserProfile, and you get full autocompletion and type checking for all its properties.
Frontend Usage
Now, let's fetch this user data on the frontend. We can reuse the exact same schema. This is where the power of a shared schema truly shines. If you put your schemas in a shared package (e.g., a common or api-schemas folder in a monorepo), both frontend and backend benefit.
import React, { useState, useEffect } from 'react';
import { UserProfile, UserProfileSchema } from './schemas'; // Reusing the schema
function UserProfileDisplay({ userId }: { userId: string }) {
const [user, setUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
// Validate the incoming data from the API response
const validatedUser = UserProfileSchema.parse(rawData);
setUser(validatedUser);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else if (err instanceof z.ZodError) {
setError('Data validation error: ' + JSON.stringify(err.errors, null, 2));
} else {
setError('An unknown error occurred');
}
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <p>Loading user data...</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
if (!user) return <p>No user found.</p>;
return (
<div>
<h2>{user.username} ({user.email})</h2>
<p>ID: {user.id}</p>
{user.age && <p>Age: {user.age}</p>}
<p>Roles: {user.roles.join(', ')}</p>
<p>Member since: {new Date(user.createdAt).toLocaleDateString()}</p>
{user.lastLoggedIn && (
<p>Last logged in: {new Date(user.lastLoggedIn).toLocaleString()}</p>
)}
</div>
);
}
// Example usage:
// <UserProfileDisplay userId="a1b2c3d4-e5f6-7890-1234-567890abcdef" />After UserProfileSchema.parse(rawData), validatedUser is now confirmed to be UserProfile. If the API sends back malformed data, it's caught before it can cause runtime errors in your UI. This is crucial for defensive programming against unexpected API responses or third-party data sources.
Trade-offs and Considerations
While Zod is fantastic, it's important to acknowledge its place. It's primarily a runtime validation library. The type inference is a powerful byproduct of that. This means:
- Bundle Size: Zod schemas add JavaScript to your bundle. For extremely performance-sensitive clients where every KB counts, this is a factor. However, for most modern web applications, the overhead is negligible given the benefits in reliability and developer experience.
- No Type-Level Logic: Zod schemas are JavaScript objects. You can't use complex TypeScript type-level features (like conditional types that depend on values) directly within Zod to generate dynamic schema shapes. For those advanced scenarios, you might still need to compose Zod types with manual TypeScript types or rely on Zod's
refineandsuperRefinemethods for more complex runtime logic. - Error Handling: While Zod's error messages are excellent, converting them into user-friendly UI messages requires some mapping. The
error.errorsarray gives you detailed context, but you'll still need to decide how to present those to your users. - Backend Integration: If your backend isn't TypeScript or doesn't use a similar schema-driven validation approach (e.g., Go with struct tags, Python with Pydantic), you might still have a

When Your Emails Need to Be as Good as Your UI: React Email
Sending emails from your application often means dealing with HTML tables, inline styles, and inconsistent rendering across clients. It's a UX nightmare. React Email brings the component model and developer experience of React to building robust, beautiful emails.

When Your UI Needs to Break the Flat Screen: React and 3D with React Three Fiber
We've been building UIs on flat screens for decades. But what happens when you need something more? When data visualization demands depth, or an interactive product showcase needs a real sense of presence? React Three Fiber is the tool that lets you bring the full power of Three.js into your React a

When Your State Management Needs to Stop Thinking in Actions and Start Mutating
Many state management libraries force you into an 'actions and reducers' pattern. While powerful, it often adds unnecessary boilerplate for simple updates. Sometimes, you just need to directly modify state, and mutators offer a more ergonomic and intuitive approach, especially for deeply nested data


















