Updated November 1, 2024

The Case for Typed Backend APIs

Why TypeScript everywhere, from frontend to API contracts, eliminates entire classes of bugs and enables fearless refactoring.

#typescript #api-design #backend

The Runtime Bug Tax

A backend bug in production costs money. Not just in infrastructure, but in:

  • Customer support time
  • Data cleanup
  • Rollback complexity
  • Lost trust

Most preventable API bugs happen at the contract boundary. Field renamed? Type changed? Nullable now when it wasn’t?

Without typing, these break at runtime. Expensive runtime.

From Zod to Type-Safe Endpoints

Zod gives you runtime validation + TypeScript type extraction:

import { z } from "zod";

const CreateProjectSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string(),
  featured: z.boolean().default(false),
  tags: z.array(z.string()).min(1),
});

type CreateProjectInput = z.infer<typeof CreateProjectSchema>;

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = CreateProjectSchema.parse(body);

  // parsed is now properly typed, IDE autocomplete works
  const project = await db.projects.create(parsed);
  return Response.json(project);
}

The schema is the single source of truth. Frontend and backend derive types from it.

Sharing Schemas Across the Stack

Put schemas in a shared package:

packages/
  api-schemas/
    src/
      projects.ts
      blog.ts
      types.ts
packages/
  web/
  api/

Both frontend and API import from @turbolense/api-schemas:

// Frontend
import { CreateProjectSchema } from "@turbolense/api-schemas";

// API
import { CreateProjectSchema } from "@turbolense/api-schemas";

Now when you change the schema, both sides break simultaneously and catch it in dev.

The Refactoring Multiplier

Last month I refactored a 3-year-old codebase from optional client field to required.

Without types: would have missed 4 callers. With types: caught them all. Zero runtime failures.

The refactoring took 2 hours instead of debugging and patching in production for a week.

Response Typing

Zod + TypeScript makes response contracts explicit:

const ProjectResponseSchema = z.object({
  id: z.string().uuid(),
  title: z.string(),
  description: z.string(),
  featured: z.boolean(),
  tags: z.array(z.string()),
  createdAt: z.date(),
  updatedAt: z.date(),
});

type ProjectResponse = z.infer<typeof ProjectResponseSchema>;

Frontend code can import this type for autocomplete:

const response = await fetch("/api/projects/123");
const project: ProjectResponse = await response.json();

// IDE knows about project.title, project.featured, etc.
// And will error if you try to access project.nonexistent

Error Handling with Schemas

Zod validation failures are structured:

try {
  const parsed = CreateProjectSchema.parse(body);
} catch (error) {
  if (error instanceof z.ZodError) {
    return Response.json(
      {
        errors: error.issues.map((issue) => ({
          path: issue.path.join("."),
          message: issue.message,
        })),
      },
      { status: 400 },
    );
  }
}

Now you return structured validation errors, and the frontend can show field-by-field feedback.

The Dependency Cost

“But we have to maintain a shared package…”

Versus: maintaining API documentation. Versus: debugging production bugs that could have been caught in dev.

The tradeoff is worth it at scale.

Checklist

  • ✅ Define Zod schemas for every API input/output
  • ✅ Extract TypeScript types from schemas with z.infer
  • ✅ Share schemas between frontend and backend packages
  • ✅ Validate on the backend, type on the frontend
  • ✅ Return structured validation errors from the API
  • ✅ Make the schema change break the build, not production

Typed APIs aren’t fancy. They’re just boring, proven bug prevention.