← Back to DevBytes

Express TypeScript: Strongly Typed Applications

Introduction to Express with TypeScript

Express has long been the de facto web framework for Node.js, prized for its minimalism and flexibility. However, its unopinionated nature means it ships without built-in type safety. By pairing Express with TypeScript, you gain a strongly typed application layer that catches errors at compile time, improves developer experience with intelligent autocompletion, and creates self-documenting codebases. This tutorial walks you through building a fully typed Express application from scratch, covering setup, configuration, patterns, and best practices.

What It Is

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Express TypeScript is the combination of the Express web framework with TypeScript's static type system. Rather than relying on untyped req and res objects, you define precise interfaces for request bodies, query parameters, route parameters, and response shapes. The compiler then enforces these contracts across your entire application. You also gain type-safe middleware, error handlers, and service layers that integrate seamlessly with Express's middleware pipeline.

Why It Matters

Project Setup

Begin by scaffolding a new Node.js project and installing the required dependencies. We'll use @types/express to bring first-class TypeScript support to Express.

mkdir express-ts-app && cd express-ts-app
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node ts-node nodemon

Create a tsconfig.json file at the project root. The configuration below enables strict mode, which is essential for maximizing TypeScript's safety guarantees.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Add a development script to package.json for hot reloading during development:

"scripts": {
  "dev": "nodemon --exec ts-node src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

Typing Request and Response Objects

The core value of Express TypeScript lies in typing the request and response objects for each route. Start by defining interfaces that describe your API contracts.

// src/types/user.ts

export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
}

export interface UpdateUserRequest {
  name?: string;
  email?: string;
}

export interface UserQueryParams {
  limit?: number;
  offset?: number;
  sortBy?: 'name' | 'email' | 'createdAt';
  order?: 'asc' | 'desc';
}

export interface UserRouteParams {
  id: string;
}

These interfaces become the backbone of your typed routes. Each route handler will explicitly declare what it expects from the request and what it returns.

Typed Route Handlers

With interfaces in place, create typed route handlers. Notice how TypeScript now enforces the shape of req.body, req.params, and req.query.

// src/routes/users.ts

import { Router, Request, Response, NextFunction } from 'express';
import {
  User,
  CreateUserRequest,
  UpdateUserRequest,
  UserQueryParams,
  UserRouteParams,
} from '../types/user';

const router = Router();

// GET /users — typed query parameters
router.get(
  '/',
  (
    req: Request<{}, {}, {}, UserQueryParams>,
    res: Response,
    next: NextFunction
  ) => {
    const limit = req.query.limit ?? 20;
    const offset = req.query.offset ?? 0;
    const sortBy = req.query.sortBy ?? 'createdAt';
    const order = req.query.order ?? 'desc';

    // TypeScript knows limit is number, sortBy is a specific string union
    const users: User[] = []; // fetch from database in real app

    res.status(200).json(users);
  }
);

// GET /users/:id — typed route params
router.get(
  '/:id',
  (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const { id } = req.params; // TypeScript knows id is a string

    // Simulated lookup
    const user: User | undefined = undefined;

    if (!user) {
      return res.status(404).json({ message: 'User not found' });
    }

    res.status(200).json(user);
  }
);

// POST /users — typed request body
router.post(
  '/',
  (
    req: Request<{}, {}, CreateUserRequest>,
    res: Response,
    next: NextFunction
  ) => {
    const { name, email, password } = req.body;
    // TypeScript validates that name, email, and password are all strings

    const newUser: User = {
      id: crypto.randomUUID(),
      name,
      email,
      createdAt: new Date(),
    };

    res.status(201).json(newUser);
  }
);

// PUT /users/:id — typed route params and body combined
router.put(
  '/:id',
  (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const { id } = req.params;
    const updates = req.body;
    // updates.email is string | undefined, perfectly typed

    const updatedUser: User = {
      id,
      name: updates.name ?? 'Unknown',
      email: updates.email ?? 'no-reply@example.com',
      createdAt: new Date(),
    };

    res.status(200).json(updatedUser);
  }
);

export default router;

The Request generic accepts four type parameters: Request<Params, ResBody, Body, Query>. When you only need to type certain parts, you can use empty objects for the others. This granular control prevents accidentally accessing properties that don't exist on a given route.

Typed Middleware

Middleware benefits enormously from typing. Custom middleware can extend the request object with additional properties, and TypeScript ensures those extensions are visible downstream.

// src/middleware/auth.ts

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

// Extend the Express Request interface globally
declare global {
  namespace Express {
    interface Request {
      currentUser?: {
        id: string;
        role: 'admin' | 'user' | 'guest';
        permissions: string[];
      };
    }
  }
}

export function authenticate(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Simulated token extraction and verification
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    res.status(401).json({ message: 'Authentication required' });
    return;
  }

  // Attach decoded user to request
  req.currentUser = {
    id: 'usr_123',
    role: 'admin',
    permissions: ['read', 'write', 'delete'],
  };

  next();
}

export function requireRole(role: 'admin' | 'user') {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (req.currentUser?.role !== role) {
      res.status(403).json({ message: 'Insufficient permissions' });
      return;
    }
    next();
  };
}

Now any route that follows the authenticate middleware has typed access to req.currentUser.

// Usage in a route file

import { authenticate, requireRole } from '../middleware/auth';

router.get(
  '/admin/dashboard',
  authenticate,
  requireRole('admin'),
  (req: Request, res: Response) => {
    // TypeScript knows req.currentUser exists and has role 'admin'
    const user = req.currentUser!;
    res.json({ message: `Welcome ${user.id}, you have ${user.permissions.length} permissions` });
  }
);

Typed Error Handling

Express error handlers are notoriously tricky to type correctly. By defining custom error classes and an explicit error-handling middleware signature, you achieve full type safety throughout the error pipeline.

// src/errors/AppError.ts

export class AppError extends Error {
  public readonly statusCode: number;
  public readonly isOperational: boolean;

  constructor(message: string, statusCode: number, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  public readonly errors: Record;

  constructor(errors: Record) {
    super('Validation failed', 400);
    this.errors = errors;
  }
}

The global error handler must use the four-parameter signature that TypeScript recognizes as an error handler.

// src/middleware/errorHandler.ts

import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // The four-parameter signature tells Express this is an error handler
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      status: 'error',
      message: err.message,
      ...(err instanceof ValidationError && { errors: (err as ValidationError).errors }),
    });
    return;
  }

  // Unknown errors — log and return generic message
  console.error('Unhandled error:', err);
  res.status(500).json({
    status: 'error',
    message: 'Internal server error',
  });
}

Register the error handler as the last middleware in your application.

// src/index.ts — application entry point

import express, { Request, Response, NextFunction } from 'express';
import usersRouter from './routes/users';
import { errorHandler } from './middleware/errorHandler';

const app = express();

// Global middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/users', usersRouter);

// 404 handler — must come before error handler
app.use((req: Request, res: Response, next: NextFunction) => {
  res.status(404).json({ message: `Route ${req.method} ${req.path} not found` });
});

// Error handler — four parameters, must be last
app.use(errorHandler);

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Service Layer Typing

Decouple route handlers from business logic by introducing a typed service layer. Services receive typed parameters and return typed promises, creating a clear contract between the transport layer and the domain logic.

// src/services/userService.ts

import { User, CreateUserRequest, UpdateUserRequest } from '../types/user';
import { NotFoundError, ValidationError } from '../errors/AppError';

// Simulated database
const users: Map = new Map();

export async function findUsers(
  limit: number,
  offset: number,
  sortBy: string,
  order: 'asc' | 'desc'
): Promise {
  const allUsers = Array.from(users.values());
  const sorted = allUsers.sort((a, b) => {
    const aValue = a[sortBy as keyof User];
    const bValue = b[sortBy as keyof User];
    if (aValue < bValue) return order === 'asc' ? -1 : 1;
    if (aValue > bValue) return order === 'asc' ? 1 : -1;
    return 0;
  });
  return sorted.slice(offset, offset + limit);
}

export async function findUserById(id: string): Promise {
  const user = users.get(id);
  if (!user) {
    throw new NotFoundError('User');
  }
  return user;
}

export async function createUser(data: CreateUserRequest): Promise {
  // Validate email uniqueness
  const exists = Array.from(users.values()).some((u) => u.email === data.email);
  if (exists) {
    throw new ValidationError({ email: 'Email already in use' });
  }

  const newUser: User = {
    id: crypto.randomUUID(),
    name: data.name,
    email: data.email,
    createdAt: new Date(),
  };

  users.set(newUser.id, newUser);
  return newUser;
}

export async function updateUser(
  id: string,
  updates: UpdateUserRequest
): Promise {
  const user = await findUserById(id);

  if (updates.email) {
    const duplicate = Array.from(users.values()).find(
      (u) => u.email === updates.email && u.id !== id
    );
    if (duplicate) {
      throw new ValidationError({ email: 'Email already in use' });
    }
  }

  const updated: User = {
    ...user,
    name: updates.name ?? user.name,
    email: updates.email ?? user.email,
  };

  users.set(id, updated);
  return updated;
}

Route handlers now become thin adapters that call services and forward errors to the error handler.

// Updated route using typed services

router.post(
  '/',
  async (
    req: Request<{}, {}, CreateUserRequest>,
    res: Response,
    next: NextFunction
  ) => {
    try {
      const newUser = await createUser(req.body);
      res.status(201).json(newUser);
    } catch (error) {
      next(error); // Forward to the typed error handler
    }
  }
);

Using Async Handler Wrappers

Express does not natively catch errors thrown in async route handlers. A typed wrapper function solves this elegantly.

// src/utils/asyncHandler.ts

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

type AsyncHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise;

export function asyncHandler(fn: AsyncHandler) {
  return (req: Request, res: Response, next: NextFunction): void => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

Use it to wrap all async route handlers without boilerplate try-catch blocks.

import { asyncHandler } from '../utils/asyncHandler';

router.post(
  '/',
  asyncHandler(
    async (
      req: Request<{}, {}, CreateUserRequest>,
      res: Response,
      next: NextFunction
    ) => {
      const newUser = await createUser(req.body);
      res.status(201).json(newUser);
    }
  )
);

Typed Environment Variables

Environment variables are a frequent source of runtime errors. TypeScript can validate them at startup.

// src/config/env.ts

interface EnvConfig {
  PORT: number;
  DATABASE_URL: string;
  JWT_SECRET: string;
  CORS_ORIGIN: string;
  NODE_ENV: 'development' | 'production' | 'test';
}

function parseEnv(): EnvConfig {
  const port = parseInt(process.env.PORT ?? '3000', 10);
  if (isNaN(port)) {
    throw new Error('PORT must be a valid number');
  }

  const databaseUrl = process.env.DATABASE_URL;
  if (!databaseUrl) {
    throw new Error('DATABASE_URL is required');
  }

  const jwtSecret = process.env.JWT_SECRET;
  if (!jwtSecret || jwtSecret.length < 32) {
    throw new Error('JWT_SECRET must be at least 32 characters');
  }

  const corsOrigin = process.env.CORS_ORIGIN ?? '*';
  const nodeEnv = process.env.NODE_ENV as EnvConfig['NODE_ENV'] | undefined;

  return {
    PORT: port,
    DATABASE_URL: databaseUrl,
    JWT_SECRET: jwtSecret,
    CORS_ORIGIN: corsOrigin,
    NODE_ENV: nodeEnv ?? 'development',
  };
}

export const env = parseEnv();

Now import env anywhere and enjoy fully typed environment variables.

Best Practices

Runtime Validation with Zod

TypeScript alone cannot validate data at runtime. Combining it with Zod gives you compile-time types and runtime validation from a single schema definition.

npm install zod
// src/schemas/userSchema.ts

import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8).max(128),
});

// Extract the TypeScript type from the Zod schema
export type CreateUserInput = z.infer;

export const updateUserSchema = z.object({
  name: z.string().min(2).max(100).optional(),
  email: z.string().email().optional(),
});

export type UpdateUserInput = z.infer;

Create a validation middleware that leverages Zod schemas.

// src/middleware/validate.ts

import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
import { ValidationError } from '../errors/AppError';

export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    try {
      const parsed = schema.parse(req.body);
      req.body = parsed; // Replace with validated and coerced data
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        const formattedErrors: Record = {};
        error.errors.forEach((e) => {
          const path = e.path.join('.');
          formattedErrors[path] = e.message;
        });
        next(new ValidationError(formattedErrors));
        return;
      }
      next(error);
    }
  };
}

Wire validation into routes seamlessly.

import { validate } from '../middleware/validate';
import { createUserSchema, CreateUserInput } from '../schemas/userSchema';

router.post(
  '/',
  validate(createUserSchema),
  asyncHandler(
    async (
      req: Request<{}, {}, CreateUserInput>,
      res: Response,
      next: NextFunction
    ) => {
      // req.body is now fully validated and typed
      const newUser = await createUser(req.body);
      res.status(201).json(newUser);
    }
  )
);

This pattern gives you the best of both worlds: compile-time type checking and runtime validation with descriptive error messages.

Complete Project Structure

A well-organized Express TypeScript project follows a clear separation of concerns.

src/
├── index.ts              # App entry point, server bootstrap
├── config/
│   └── env.ts            # Typed environment variables
├── types/
│   ├── user.ts           # Domain interfaces
│   └── order.ts          # More domain interfaces
├── schemas/
│   ├── userSchema.ts     # Zod validation schemas + inferred types
│   └── orderSchema.ts
├── middleware/
│   ├── auth.ts           # Authentication and authorization
│   ├── errorHandler.ts   # Global error handler
│   └── validate.ts       # Zod validation middleware
├── routes/
│   ├── users.ts          # User route handlers
│   └── orders.ts         # Order route handlers
├── services/
│   ├── userService.ts    # User business logic
│   └── orderService.ts   # Order business logic
├── errors/
│   └── AppError.ts       # Custom error hierarchy
└── utils/
    └── asyncHandler.ts   # Async wrapper utility

Conclusion

Pairing Express with TypeScript transforms a traditionally dynamic framework into a strongly typed, predictable foundation for web applications. By typing request parameters, bodies, and responses, you eliminate entire categories of runtime bugs. Middleware becomes safer through declaration merging and custom error classes. The service layer pattern decouples transport concerns from business logic, making the application testable and maintainable. Adding Zod bridges the gap between compile-time and runtime safety, ensuring that even data from external sources adheres to your contracts. With strict mode enabled, async handlers wrapped, and environment variables validated at startup, your Express application becomes remarkably resilient. The initial investment in typing pays dividends throughout the entire development lifecycle, from faster onboarding to fearless refactoring and production stability.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles