Express.js REST API with TypeScript — Production Guide 2024
Advertisement
Introduction
Why This Matters
Express.js is the most deployed Node.js framework in the world with over 20 million weekly npm downloads in 2024. Despite newer alternatives, Express remains the first choice for teams that need a proven, well-documented, ecosystem-rich foundation. When combined with TypeScript, Express becomes fully type-safe — from request bodies to response shapes.
Without TypeScript, Express APIs are fragile. A wrong property name in req.body, an unchecked req.params.id, or a forgotten null check in a middleware silently causes 500 errors. TypeScript catches all of these at compile time.
This guide covers production Express patterns: typed request/response, validated input, structured error handling, and the middleware patterns that prevent boilerplate without sacrificing type safety.
Setup and Dependencies
npm install express cors helmet dotenv
npm install --save-dev typescript @types/express @types/cors tsxType-Safe App Factory
Always separate app creation from server startup for testability.
// src/app.ts
import express, { Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { userRouter } from './features/users/user.router';
import { errorHandler } from './middleware/error-handler';
export function createApp(): Express {
const app = express();
// Security headers
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Body parsing
app.use(express.json({ limit: '10kb' }));
// Routes
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use('/api/v1/users', userRouter);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ status: 'error', message: 'Route not found' });
});
// Error handler (must be last)
app.use(errorHandler);
return app;
}Typed Request Interfaces
// src/types/express.d.ts
import { User } from '../features/users/user.types';
declare global {
namespace Express {
interface Request {
user?: User;
requestId?: string;
}
}
}// Typed request body and params
import { Request, Response, NextFunction } from 'express';
interface CreateUserBody {
name: string;
email: string;
password: string;
role?: 'admin' | 'user';
}
interface UserParams {
id: string;
}
interface UserQuery {
page?: string;
limit?: string;
role?: string;
}
// Fully typed handler
async function createUser(
req: Request<{}, {}, CreateUserBody, {}>,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { name, email, password, role = 'user' } = req.body;
const user = await userService.create({ name, email, password, role });
res.status(201).json({ status: 'success', data: user });
} catch (error) {
next(error);
}
}Router Organization
// src/features/users/user.router.ts
import { Router } from 'express';
import { authenticate } from '../../middleware/authenticate';
import { validateBody } from '../../middleware/validate';
import { createUserSchema, updateUserSchema } from './user.validation';
import * as userController from './user.controller';
export const userRouter = Router();
// Public routes
userRouter.post('/', validateBody(createUserSchema), userController.createUser);
// Protected routes
userRouter.use(authenticate);
userRouter.get('/', userController.listUsers);
userRouter.get('/:id', userController.getUserById);
userRouter.put('/:id', validateBody(updateUserSchema), userController.updateUser);
userRouter.delete('/:id', userController.deleteUser);Async Handler Wrapper
Eliminate try/catch boilerplate in every handler.
// src/middleware/async-handler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncRequestHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void | Response>;
export function asyncHandler(fn: AsyncRequestHandler): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
// Usage
export const getUserById = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
res.status(404).json({ status: 'error', message: 'User not found' });
return;
}
res.json({ status: 'success', data: user });
});Input Validation Middleware
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
export function validateBody<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
return;
}
req.body = result.data;
next();
};
}
// Zod schemas
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'user']).optional().default('user'),
});Centralized Error Handler
// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number,
public readonly code: string = 'APP_ERROR'
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
error: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
code: error.code,
});
return;
}
if (error instanceof ZodError) {
res.status(400).json({
status: 'error',
message: 'Validation error',
errors: error.errors,
});
return;
}
console.error('[Unhandled Error]', error);
res.status(500).json({
status: 'error',
message: 'Internal server error',
});
}Common Mistakes
- Forgetting the
nextparameter in error handler middleware — Express requires all four params - Using
res.send()afternext(error)— causes "cannot set headers after they are sent" - Not adding
helmet()— leaves security headers unset in production - Using synchronous
JSON.parse()on request body in custom middleware — Express already does this - Ordering error handler before other routes — Express processes middleware top to bottom
Best Practices
- Use
asyncHandleror a similar wrapper on every async route — never let unhandled rejections crash the process - Validate all request input with Zod or Joi before touching
req.bodyin controllers - Use TypeScript declaration merging to extend
express.Requestwith custom properties likereq.user - Set request body size limits (
express.json({ limit: '10kb' })) to prevent payload attacks - Structure routes by feature directory, not by HTTP verb —
features/users/notroutes/,controllers/
Key Takeaways
- Separate
createApp()fromapp.listen()— enables clean integration tests without starting a real server - Type
req.bodywith generic parameters onRequest<Params, ResBody, ReqBody, Query>for full type safety - Use declaration merging in
express.d.tsto add custom properties toRequestglobally - Always register error handler middleware last — Express identifies it by the four-parameter signature
asyncHandleris the simplest pattern to propagate async errors to Express error middleware- Zod provides runtime validation that aligns with TypeScript types — use it for all external input
helmet()sets security headers with one line — always include it in production Express apps- Feature-based directory structure (
features/users/) scales better than layer-based organization
Advertisement