Nathaniel's blog
Back to posts

Building Type-Safe APIs with TypeScript and Zod

Nathaniel LinJanuary 19, 20268 min read0 views
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.

Share this post

Reactions