Building Type-Safe APIs with TypeScript
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
- Share types between client and server
- Validate inputs and outputs at the boundary
- 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.