Express.js REST API with TypeScript — Production Guide 2024

Sanjeev SharmaSanjeev Sharma
6 min read

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 tsx

Type-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 next parameter in error handler middleware — Express requires all four params
  • Using res.send() after next(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 asyncHandler or 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.body in controllers
  • Use TypeScript declaration merging to extend express.Request with custom properties like req.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/ not routes/, controllers/

Key Takeaways

  • Separate createApp() from app.listen() — enables clean integration tests without starting a real server
  • Type req.body with generic parameters on Request&lt;Params, ResBody, ReqBody, Query> for full type safety
  • Use declaration merging in express.d.ts to add custom properties to Request globally
  • Always register error handler middleware last — Express identifies it by the four-parameter signature
  • asyncHandler is 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading