Building Type-Safe APIs with TypeScript and Zod
Runtime type checking has always been a pain point in TypeScript projects. You write beautiful interfaces, but the moment data crosses a boundary — an API request, a database row, a JSON file — all bets are off. Zod bridges that gap elegantly.
Why Zod?
TypeScript's type system is erased at compile time. That means your carefully typed UserInput interface does exactly nothing when a malformed payload hits your endpoint:
import { z } from "zod";
const CreatePostInput = z.object({
title: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/),
content: z.string(),
visibility: z.enum(["draft", "published", "private"]),
tags: z.array(z.string().uuid()).optional(),
});
type CreatePostInput = z.infer<typeof CreatePostInput>;
The beauty here is dual-use: CreatePostInput is both a runtime validator and a TypeScript type. Parse once, trust everywhere.
Integration with Hono
In a Hono route handler, validation becomes a one-liner:
app.post("/posts", async (c) => {
const body = CreatePostInput.parse(await c.req.json());
// body is fully typed — IDE autocomplete works
const post = await db.insert(posts).values(body).returning();
return c.json(post);
});
If validation fails, Zod throws a ZodError with structured issue details. Wrap it in middleware to return clean 400 responses:
app.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({ errors: err.issues }, 400);
}
throw err;
});
Composing Schemas
Zod shines when you need to derive schemas from a base. Need an update schema where every field is optional?
const UpdatePostInput = CreatePostInput.partial();
Need a response schema that includes server-generated fields?
const PostResponse = CreatePostInput.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
viewCount: z.number().int().nonneg(),
});
Working with Drizzle ORM
When combining Zod with Drizzle, you get end-to-end type safety from HTTP request to database query:
const validated = CreatePostInput.parse(rawInput);
const [created] = await db
.insert(posts)
.values({
...validated,
ownerId: ctx.user.id,
blogId: ctx.blog.id,
})
.returning();
Drizzle's own types catch any mismatch between your Zod schema and the actual table columns at compile time. This two-layer approach — Zod for runtime, Drizzle for compile-time — catches errors that either system alone would miss.
Practical Tips
-
Coercion helpers: Use
z.coerce.number()for query params that arrive as strings -
Transform chains:
.transform(s => s.toLowerCase().trim())for slugs -
Refinements:
.refine(async (slug) => !(await slugExists(slug)), "Slug taken")for async validation -
Error maps: Customize messages per-field with
.describe()or custom error maps
Type-safe APIs are not about more code — they're about moving errors from runtime to development time. Zod makes that shift almost effortless.