Skip to content

Building Type-Safe APIs with TypeScript

Dec 20259 min read

Type safety across the stack cuts whole classes of bugs and makes refactors calm. This is the short, practical version.

The Problem

Classic REST APIs break when the server response shape changes but the client code does not.

The Minimal Fix

Share types and validate at the boundary.

// types/user.ts
export interface User {
  id: number;
  email: string;
  name: string;
  role: "admin" | "user";
}

export interface CreateUserRequest {
  email: string;
  name: string;
  password: string;
}
// schema/user.ts
import { z } from "zod";

export const userSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string().min(1),
  role: z.enum(["admin", "user"]),
});

export const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  password: z.string().min(8),
});

export type User = z.infer<typeof userSchema>;
// routes/users.ts
import { Request, Response } from "express";
import {
  userSchema,
  createUserSchema,
  User,
  CreateUserRequest,
} from "../types/user";

export async function getUser(
  req: Request<{ id: string }>,
  res: Response<User>,
) {
  const userId = Number(req.params.id);
  const user = await db.users.findById(userId);
  if (!user) return res.status(404).json({ error: "User not found" });
  res.json(userSchema.parse(user));
}

export async function createUser(
  req: Request<{}, {}, CreateUserRequest>,
  res: Response<User>,
) {
  const body = createUserSchema.parse(req.body);
  const user = await db.users.create(body);
  res.json(user);
}

If You Want the Full Experience

tRPC removes the client/server type gap entirely. Prisma gives you typed database queries. Combine them and you get end-to-end type safety with very little manual glue.

Simple Rules

  1. Share types between client and server
  2. Validate inputs and outputs at the boundary
  3. Prefer generated types when possible

Conclusion

You do not need a huge framework to get type-safe APIs. Start by sharing types and validating at the edge. That alone prevents most of the painful runtime surprises.

Related Posts