Building Type-Safe APIs with TypeScript and Zod
•
0 views
•0 likes

Building Type-Safe APIs with TypeScript and Zod
TypeScript gives us compile-time type safety, but what about runtime? In this post, we'll explore how to use Zod for runtime validation that stays in sync with your TypeScript types.
The Problem
TypeScript types are erased at runtime. This means:
typescript
type User = {
name: string;
email: string;
age: number;
};
// This compiles, but at runtime, anything could come in!
async function createUser(data: User) {
// data could be anything from an API request
}
Enter Zod
Zod is a TypeScript-first schema validation library. Install it:
bash
npm install zod
Defining Schemas
Create a Zod schema that validates your data:
typescript
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.number().min(0).max(120),
});
// Infer the TypeScript type from the schema!
type User = z.infer<typeof UserSchema>;
Validating Data
Use the schema to validate incoming data:
typescript
function createUser(data: unknown) {
// Parse and validate
const result = UserSchema.safeParse(data);
if (!result.success) {
// Handle validation errors
console.error(result.error.issues);
return { error: "Validation failed" };
}
// result.data is now fully typed as User!
const user = result.data;
console.log(`Creating user: ${user.name}`);
return { success: true, user };
}
API Route Example
Here's a complete Next.js API route with Zod:
typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = CreateUserSchema.parse(body);
// data is now typed and validated!
const user = await db.user.create({
data: {
name: data.name,
email: data.email,
password: await hash(data.password),
},
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ errors: error.issues },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
Advanced Patterns
Optional and Default Values
typescript
const ConfigSchema = z.object({
port: z.number().default(3000),
debug: z.boolean().optional(),
environment: z.enum(["development", "production", "test"]),
});
Transformations
typescript
const DateSchema = z.string().transform((val) => new Date(val));
const UserWithDate = z.object({
name: z.string(),
createdAt: DateSchema,
});
Nested Objects and Arrays
typescript
const OrderSchema = z.object({
id: z.string().uuid(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
})
),
total: z.number().positive(),
});
Best Practices
- Define schemas once, derive types - Never duplicate type definitions
- Use safeParse for user input - It returns errors instead of throwing
- Create reusable schemas - Export common schemas like
EmailSchema - Add custom error messages - Make errors user-friendly
Conclusion
Zod bridges the gap between compile-time and runtime type safety. Combined with TypeScript, you get a robust system that catches errors at every level of your application.
Start using Zod in your next project and experience the peace of mind that comes with true type safety!
TypeScriptZodAPI