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
- Compile-time safety: Misspelled properties, wrong types on request bodies, and incorrect response structures are caught before the application ever runs.
- Superior autocompletion: IDEs provide precise suggestions for
req.body.email,req.params.id, and middleware properties, eliminating guesswork. - Self-documenting code: Interfaces act as living documentation. A new developer can read route type definitions and immediately understand the expected input and output shapes.
- Refactoring confidence: Renaming a field in a shared interface propagates type errors everywhere it's used, guiding you through safe, comprehensive refactors.
- Production reliability: Fewer runtime crashes due to unexpected
undefinedor type coercion bugs that are notoriously hard to debug in production logs.
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
- Enable strict mode: Always set
"strict": truein tsconfig. It enablesnoImplicitAny,strictNullChecks, and other flags that catch subtle bugs. - Use dedicated type files: Keep interfaces in
src/types/organized by domain (e.g.,user.ts,order.ts). Avoid inline type definitions in route files. - Prefer the asyncHandler pattern: Wrap all async route handlers to avoid unhandled promise rejections crashing the server.
- Extend Request via declaration merging: Use
declare global { namespace Express { interface Request { ... } } }rather than type assertions for attaching custom properties toreq. - Create custom error classes: Distinguish operational errors (validation, not found) from programmer errors. The error handler can then decide what to expose to the client.
- Validate at the boundary: Use libraries like
zodorjoiwith TypeScript to validate request bodies at runtime. TypeScript catches structural errors, but runtime validation catches semantic errors like string lengths and regex patterns. - Keep route handlers thin: Route handlers should parse input, call a service, and send a response. Business logic belongs in typed service functions.
- Use discriminated unions for responses: When an endpoint returns multiple shapes (e.g., success vs. error), define a union type so consumers can narrow safely.
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.