Building a Type-Safe REST API with Express and TypeScript
Stop guessing what your request bodies look like. Here is how to wire up Express with TypeScript for fully typed routes, validated payloads, and sane error handling.

Every React or Next.js project eventually needs a backend. Sometimes Vercel serverless functions are enough, but when you need persistent connections, complex business logic, or a standalone service that multiple clients share, a dedicated Express API is hard to beat. Add TypeScript and you get autocomplete, compile-time safety, and the confidence to refactor without hunting down every req.body.userId.
This post walks through setting up a small but real Express + TypeScript API from scratch — typed request handlers, Zod input validation, a proper error middleware, and a CRUD example you can build on.
Project Setup
mkdir express-ts-api && cd express-ts-api
npm init -y
npm install express zod
npm install -D typescript tsx @types/express @types/node
npx tsc --initAdjust tsconfig.json to target modern Node:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
}
}Add scripts to package.json:
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}The Project Structure
src/
index.ts # server entry, middleware wiring
routes/
tasks.ts # route handlers for /tasks
middleware/
errorHandler.ts
schemas/
task.ts # Zod schemas + inferred typesKeeping schemas separate means your validation logic and your TypeScript types live in one place.
Define Your Schemas First
Rather than writing interfaces and validators separately, define them once in Zod and infer the TypeScript type from the schema. This is the same pattern from the Zod post, applied to the backend.
// src/schemas/task.ts
import { z } from 'zod';
const TaskFields = z.object({
title: z.string().min(1).max(200),
done: z.boolean(),
});
// POST: title is required, done defaults to false
export const NewTaskSchema = TaskFields.extend({
done: z.boolean().default(false),
});
// PATCH: every field is optional, with no defaults
export const TaskPatchSchema = TaskFields.partial();
export type NewTask = z.infer<typeof NewTaskSchema>;
export type Task = NewTask & { id: string };Typed Route Handlers
Express ships RequestHandler and the generic overload Request<Params, ResBody, ReqBody, Query>. Use them:
// src/routes/tasks.ts
import { Router, Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
import { NewTaskSchema, TaskPatchSchema, Task } from '../schemas/task';
const router = Router();
// In-memory store for this example — swap for a DB call
const tasks: Task[] = [];
// GET /tasks
router.get('/', (_req: Request, res: Response<Task[]>) => {
res.json(tasks);
});
// POST /tasks
router.post('/', (req: Request, res: Response<Task>, next: NextFunction) => {
const parsed = NewTaskSchema.safeParse(req.body);
if (!parsed.success) {
// Pass a structured error to the error middleware
const err = Object.assign(new Error('Validation failed'), {
status: 400,
details: parsed.error.flatten().fieldErrors,
});
return next(err);
}
const task: Task = { id: randomUUID(), ...parsed.data };
tasks.push(task);
res.status(201).json(task);
});
// GET /tasks/:id
router.get('/:id', (req: Request<{ id: string }>, res: Response, next: NextFunction) => {
const task = tasks.find(t => t.id === req.params.id);
if (!task) return next(Object.assign(new Error('Not found'), { status: 404 }));
res.json(task);
});
// PATCH /tasks/:id
router.patch('/:id', (req: Request<{ id: string }>, res: Response, next: NextFunction) => {
const idx = tasks.findIndex(t => t.id === req.params.id);
if (idx === -1) return next(Object.assign(new Error('Not found'), { status: 404 }));
const parsed = TaskPatchSchema.safeParse(req.body);
if (!parsed.success) {
return next(Object.assign(new Error('Validation failed'), {
status: 400,
details: parsed.error.flatten().fieldErrors,
}));
}
tasks[idx] = { ...tasks[idx], ...parsed.data };
res.json(tasks[idx]);
});
// DELETE /tasks/:id
router.delete('/:id', (req: Request<{ id: string }>, res: Response, next: NextFunction) => {
const idx = tasks.findIndex(t => t.id === req.params.id);
if (idx === -1) return next(Object.assign(new Error('Not found'), { status: 404 }));
tasks.splice(idx, 1);
res.status(204).send();
});
export default router;Notice the PATCH schema is built from the same fields with .partial(), without the default. If done kept .default(false), a PATCH that only changes the title could quietly reset done.
Error Middleware
Express recognizes an error handler by its four arguments, so _next has to stay in the signature even though it's unused. Anything unexpected returns a generic message, so internal details don't leak to clients:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
interface AppError extends Error {
status?: number;
details?: Record<string, string[]>;
}
export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction,
) {
const status = err.status ?? 500;
res.status(status).json({
error: status < 500 ? err.message : 'Internal server error',
...(err.details ? { details: err.details } : {}),
});
}Wire It All Together
// src/index.ts
import express from 'express';
import taskRouter from './routes/tasks';
import { errorHandler } from './middleware/errorHandler';
const app = express();
app.use(express.json());
app.use('/tasks', taskRouter);
// Error handler must be last
app.use(errorHandler);
const PORT = process.env.PORT ?? 3001;
app.listen(PORT, () => console.log(`API running on :${PORT}`));Try It Out
npm run dev
# Create a task
curl -X POST http://localhost:3001/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Write tests"}'
# Bad payload — watch the 400 with field errors
curl -X POST http://localhost:3001/tasks \
-H 'Content-Type: application/json' \
-d '{"title": ""}'The second request returns:
{
"error": "Validation failed",
"details": { "title": ["Too small: expected string to have >=1 characters"] }
}The exact wording comes from Zod and varies by version, but the structure gives your frontend a field-by-field error to work with.
What to Add Next
This skeleton intentionally leaves out a few things that depend on your use case:
- Database: swap the in-memory array for
pgor Prisma calls — the route shape stays the same, though handlers becomeasync. Express 5 forwards rejected promises to the error middleware automatically. - Auth: a JWT middleware sits cleanly between
express.json()and your routes, and you can type the decoded payload ontores.localsto propagate it through the call. - CORS: add
corsfrom npm before your routes for cross-origin browser requests.
The pattern here scales well — schemas stay honest at the boundary, types flow from the schema into the handlers, and the error middleware keeps every route tidy. req.body is still any at the boundary, but after safeParse you're working with a fully typed parsed.data — and that's the difference that matters.