TypeScript gives us compile-time type safety, but that safety ends at runtime boundaries. When data comes from external sources—API requests, form inputs, third-party services—TypeScript can’t help us. This is where Zod shines.
The Problem with TypeScript Alone
Consider a typical API endpoint:
interface CreateUserRequest {
name: string;
email: string;
age: number;
}
app.post('/users', (req, res) => {
const user: CreateUserRequest = req.body;
// TypeScript trusts this completely
// But what if req.body is actually { name: 123, email: null }?
});
TypeScript assumes req.body matches our interface. In reality, anything could come over the wire. We need runtime validation.
Enter Zod
Zod lets us define schemas that validate at runtime and infer TypeScript types:
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().max(150),
});
// TypeScript type derived from the schema
type CreateUserRequest = z.infer<typeof CreateUserSchema>;
Now our type and validation live in the same place. One source of truth.
Validating API Requests
Here’s how to use Zod in an Express handler:
app.post('/users', (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
// result.data is fully typed as CreateUserRequest
const user = result.data;
// user.name is guaranteed to be a non-empty string
// user.email is guaranteed to be a valid email
// user.age is guaranteed to be a positive integer
});
The safeParse method returns a discriminated union. If validation fails, you get structured error messages. If it succeeds, you get fully-typed data.
Composing Complex Schemas
Zod schemas compose naturally:
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2), // ISO country code
postalCode: z.string().optional(),
});
const UserWithAddressSchema = CreateUserSchema.extend({
address: AddressSchema.optional(),
roles: z.array(z.enum(['admin', 'user', 'guest'])),
});
Error Handling Patterns
Zod errors are detailed and can be formatted for different needs:
const result = schema.safeParse(data);
if (!result.success) {
// Flat format for simple error messages
const flat = result.error.flatten();
// { fieldErrors: { email: ['Invalid email'] } }
// Full format for detailed debugging
const issues = result.error.issues;
// [{ path: ['email'], message: 'Invalid email', code: 'invalid_string' }]
}
Type Inference Tricks
Zod’s type inference is powerful. You can extract input vs output types:
const TransformSchema = z.object({
dateString: z.string().transform(s => new Date(s)),
});
type Input = z.input<typeof TransformSchema>;
// { dateString: string }
type Output = z.output<typeof TransformSchema>;
// { dateString: Date }
Conclusion
Zod bridges the gap between TypeScript’s static types and runtime reality. Define your schema once, get both validation and types. Your API contracts become self-documenting and self-enforcing.
The initial setup cost is minimal, but the payoff—catching malformed data before it corrupts your system—is substantial.