TypeScript with Node/Express

Difficulty: Intermediate

Question

How do you use TypeScript with Node.js and Express? Show patterns for typed middleware, routes, and error handling.

Answer

TypeScript with Node.js/Express provides type safety across your entire backend. Key patterns:

- Install @types/node and @types/express for base types - Type Request with generics: Request<Params, ResBody, ReqBody, Query> - Augment Express types to add custom properties (user, requestId) - Create typed middleware with proper next() signatures - Use generic response wrappers for consistent API responses

The main challenge is typing middleware chains where each middleware adds properties to the request. Module augmentation and generic request types solve this.

Code examples

Typed Express Routes

import { Request, Response, Router } from 'express';

interface User {
  id: number;
  name: string;
  email: string;
}

// Typed request generics: Request<Params, ResBody, ReqBody, Query>
interface CreateUserBody {
  name: string;
  email: string;
  password: string;
}

interface UserParams {
  id: string;
}

interface UserQuery {
  include?: 'posts' | 'comments';
}

const router = Router();

// GET with typed params and query
router.get('/:id', async (
  req: Request<UserParams, User, never, UserQuery>,
  res: Response<User>
) => {
  const userId = parseInt(req.params.id); // string from params
  const include = req.query.include;       // 'posts' | 'comments' | undefined
  const user = await findUser(userId, include);
  res.json(user); // must match User type
});

// POST with typed body
router.post('/', async (
  req: Request<never, User, CreateUserBody>,
  res: Response<User>
) => {
  const { name, email, password } = req.body; // fully typed
  const user = await createUser({ name, email, password });
  res.status(201).json(user);
});

Express Request accepts generics for Params, Response Body, Request Body, and Query. This provides autocompletion and type checking for all parts of the request/response cycle.

Typed Middleware

import { Request, Response, NextFunction } from 'express';

// Augment Express Request (in a .d.ts file)
declare module 'express-serve-static-core' {
  interface Request {
    user?: { id: number; role: 'admin' | 'user' };
    startTime: number;
  }
}

// Auth middleware
function authenticate(req: Request, res: Response, next: NextFunction): void {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) {
    res.status(401).json({ error: 'No token provided' });
    return;
  }
  try {
    req.user = verifyJWT(token); // typed as { id: number; role: string }
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

// Role-based authorization
function authorize(...roles: ('admin' | 'user')[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user || !roles.includes(req.user.role)) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }
    next();
  };
}

// Timing middleware
function timing(req: Request, res: Response, next: NextFunction): void {
  req.startTime = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} - ${Date.now() - req.startTime}ms`);
  });
  next();
}

// Usage in routes
router.get('/admin/users', authenticate, authorize('admin'), getUsers);

Module augmentation adds custom properties to Request. Middleware functions use the standard (req, res, next) signature. Factory middleware (authorize) returns a closure.

Typed Error Handling

// Custom error class
class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public code: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }

  static badRequest(message: string): AppError {
    return new AppError(400, message, 'BAD_REQUEST');
  }

  static notFound(resource: string): AppError {
    return new AppError(404, `${resource} not found`, 'NOT_FOUND');
  }

  static unauthorized(): AppError {
    return new AppError(401, 'Unauthorized', 'UNAUTHORIZED');
  }
}

// Typed API response wrapper
interface ApiResponse<T = unknown> {
  success: boolean;
  data?: T;
  error?: { message: string; code: string };
}

// Global error handler middleware
function errorHandler(
  err: Error,
  req: Request,
  res: Response<ApiResponse>,
  next: NextFunction
): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      error: { message: err.message, code: err.code },
    });
    return;
  }
  console.error('Unhandled error:', err);
  res.status(500).json({
    success: false,
    error: { message: 'Internal server error', code: 'INTERNAL' },
  });
}

// Usage in routes
router.get('/:id', async (req, res, next) => {
  try {
    const user = await findUser(req.params.id);
    if (!user) throw AppError.notFound('User');
    res.json({ success: true, data: user });
  } catch (err) {
    next(err); // passes to errorHandler
  }
});

Custom error classes with static factory methods provide clean, typed error creation. The global error handler uses the 4-parameter Express signature for centralized error handling.

Key points

Concepts covered

Express Typing, Request/Response Types, Middleware Typing, Error Handling, Typed Routes