How to use Express with TypeScript to make apps easier to build and maintain
Open @types/express and look at the type of req.body. It's any. Not unknown. Not a generic parameter. Just any, the TypeScript escape hatch that turns off type checking entirely. That's not an oversight. Express shipped years before TypeScript existed, and the community added type definitions afterward. The framework itself has no opinion about what arrives in your request body, so the types don't either.
That's the core tension with Express and TypeScript: the type safety doesn't come for free. You have to build it yourself. The good news is that once you do, the combination is genuinely powerful. You get typed routes, typed middleware chains, compile-time errors on malformed request data, and a codebase that new developers can navigate without guessing at data shapes.
Getting there requires a few deliberate choices: proper request generics, module augmentation for middleware properties, runtime validation with Zod, and if you're on Express 5, native async error propagation that replaces a lot of boilerplate. This guide covers all of it.
Setting Up an Express Server with TypeScript
To set up an Express server using TypeScript, follow these steps:
- Create a new Node.js project and install necessary dependencies:
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node ts-node nodemon
Note: Express 5 is now the stable release (requires Node.js 18+) and is the recommended version for new projects. Install it explicitly with
npm install express@5. If you prefer a faster dev server without a separate compilation step,tsxis a modern alternative tots-node:npm install --save-dev tsx.
- Initialize a TypeScript project and configure the
tsconfig.json:
npx tsc --init
- Update your
tsconfig.jsonto work with Express:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
- Create your first Express server in TypeScript:
// src/app.ts
import express, { Request, Response } from 'express';
const app = express();
const port = 3000;
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});
- Add scripts to your
package.jsonfor development and building:
"scripts": {
"dev": "nodemon src/app.ts",
"build": "tsc",
"start": "node build/app.js"
}
If you're using tsx instead of ts-node, change the dev script to "dev": "tsx watch src/app.ts".
You now have hot reloading via npm run dev and production builds with npm run build && npm start.
Handling Routes in Express with TypeScript
TypeScript lets you type route parameters, request bodies, and response shapes directly on your handlers. Here's how that looks in practice with a Router:
// src/routes/user.ts
import { Router, Request, Response } from 'express';
// Define a type for the expected user data structure
interface User {
id: string;
name: string;
email: string;
}
const userRouter = Router();
// Route with typed parameters and response
userRouter.get('/:id', (req: Request, res: Response) => {
const userId = req.params.id;
// In a real app, you would fetch user data from a database
const user: User = {
id: userId,
name: 'John Doe',
email: 'john.doe@example.com'
};
res.json(user);
});
// Adding a POST route with request body typing
userRouter.post('/', (req: Request<{}, {}, User>, res: Response) => {
const newUser = req.body;
// Validate and save user to database
res.status(201).json(newUser);
});
export default userRouter;
To use these routes in your main application file:
// src/app.ts
import express from 'express';
import userRouter from './routes/user';
const app = express();
const port = 3000;
// Middleware for parsing JSON bodies
app.use(express.json());
// Mount the user routes
app.use('/users', userRouter);
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});
Using interfaces to define data shapes keeps your route handlers consistent and gives TypeScript enough context to catch mismatches at compile time.
Integrating Middleware in an Express App with TypeScript
Middleware type signatures matter more than they might seem. Without them, properties you set on req won't be visible to downstream handlers, and the compiler can't tell you when you've passed the wrong middleware type to a route. Here's an auth middleware typed with generics:
// src/middleware/auth.ts
import { NextFunction, Request, Response } from 'express';
// Define a user interface
interface User {
id: string;
role: string;
}
// Using generics to extend the Request object
interface RequestWithUser<T = User> extends Request {
user?: T;
}
const authenticate = <T extends User>(
req: RequestWithUser<T>,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
// In a real app, you would validate the token
const token = authHeader.split(' ')[1];
// Set the user property on the request for use in route handlers
req.user = {
id: '123',
role: 'admin'
} as T;
next();
} else {
res.status(401).send('Unauthorized');
}
};
export default authenticate;
To apply this middleware to your application:
// src/app.ts
import express from 'express';
import userRouter from './routes/user';
import authenticate from './middleware/auth';
const app = express();
// Global middleware
app.use(express.json());
// Protect all routes under /protected
app.use('/protected', authenticate, userRouter);
// Public routes don't use the auth middleware
app.use('/public', userRouter);
app.listen(3000, () => {
console.log('Server started on port 3000');
});
This example shows how to use generics<T> to create flexible middleware that can work with different user types while maintaining type safety. While Express heavily relies on middleware for customization, this approach differs from Convex's philosophy on custom functions, which recommends avoiding middleware in favor of explicit, discoverable function customization for backend APIs.
Runtime Request Validation with Zod
Here's something TypeScript's type system can't do on its own: validate data at runtime. When req.body arrives from a client, TypeScript has no idea whether it actually matches your interface. You might annotate req.body as User, but if the client sends garbage, your code will happily accept it.
Zod bridges this gap. You define a schema once, get runtime validation, and Zod infers the TypeScript type automatically. No separate interface needed.
npm install zod
// src/schemas/user.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
});
// Infer the TypeScript type from the schema - no separate interface needed
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
Now create a reusable validation middleware:
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
export const validate = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// ZodError gives you field-by-field error details
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
// Replace req.body with the validated, typed data
req.body = result.data;
next();
};
};
Apply it to your routes:
// src/routes/user.ts
import { Router, Request, Response } from 'express';
import { validate } from '../middleware/validate';
import { CreateUserSchema, CreateUserInput } from '../schemas/user';
const userRouter = Router();
userRouter.post(
'/',
validate(CreateUserSchema),
(req: Request<{}, {}, CreateUserInput>, res: Response) => {
// req.body is now fully typed AND validated at runtime
const { name, email, role } = req.body;
res.status(201).json({ name, email, role });
}
);
export default userRouter;
The key benefit: if a client omits a required field or sends a malformed email, your handler never runs. The validation middleware catches it first and returns a clean 400 response with specific field-level errors.
Implementing Error Handling in Express using TypeScript
Express error handling middleware has a specific signature: four parameters, with err first. TypeScript needs that signature to recognize the function as error-handling middleware rather than a regular middleware. Start by defining a typed error class hierarchy:
// src/types/errors.ts
// Define custom error types
export class ApplicationError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number = 500) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends ApplicationError {
constructor(message: string = 'Resource not found') {
super(message, 404);
}
}
export class UnauthorizedError extends ApplicationError {
constructor(message: string = 'Unauthorized access') {
super(message, 401);
}
}
Now, create a middleware to handle these custom errors:
// src/middleware/errorHandler.ts
import { NextFunction, Request, Response } from 'express';
import { ApplicationError } from '../types/errors';
// Error handling middleware
const errorHandler = (
err: Error | ApplicationError,
req: Request,
res: Response,
next: NextFunction
) => {
console.error(`Error: ${err.message}`);
// Check if this is our custom error type
if (err instanceof ApplicationError) {
return res.status(err.statusCode).json({
error: {
message: err.message,
status: err.statusCode
}
});
}
// Handle unexpected errors
return res.status(500).json({
error: {
message: 'Internal Server Error',
status: 500
}
});
};
export default errorHandler;
To use these error handling components in your application:
// src/app.ts
import express from 'express';
import userRouter from './routes/user';
import errorHandler from './middleware/errorHandler';
import { NotFoundError } from './types/errors';
const app = express();
// Request parsing middleware
app.use(express.json());
// Routes
app.use('/users', userRouter);
// 404 handler for undefined routes
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.path} not found`));
});
// Error handling middleware (must be last)
app.use(errorHandler);
app.listen(3000, () => {
console.log('Server started on port 3000');
});
Using classes for your error hierarchy means every thrown error carries a statusCode the handler can read directly. No more switch statements on error messages or string matching to figure out what HTTP status to return.
Express 5: Async Error Handling Without the Wrapper
If you're on Express 4, you've probably run into this problem: throwing inside an async route handler doesn't reach your error middleware. You need to wrap everything in try/catch, or install a package like express-async-errors.
Express 5 fixes this natively. When a route handler returns a rejected Promise, Express 5 automatically forwards the error to your error middleware. No wrapper needed.
Here's what the Express 4 pattern typically looks like:
// Express 4: manual async error handling
app.get('/profile/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await fetchUserProfile(req.params.id);
res.json(profile);
} catch (error) {
next(error); // must manually forward to error middleware
}
});
With Express 5, the same route becomes:
// Express 5: rejected promises are forwarded to error middleware automatically
app.get('/profile/:id', async (req: Request, res: Response) => {
const profile = await fetchUserProfile(req.params.id); // throws on error
res.json(profile); // only runs if above succeeds
});
If fetchUserProfile throws or rejects, Express 5 catches it and passes it to your error handling middleware. Your custom errorHandler shown above works exactly the same way. This removes an entire category of subtle bugs where async errors silently swallow exceptions.
Using Decorators in Express with TypeScript
If you prefer a class-based routing style, TypeScript decorators let you define routes and middleware declaratively on controller methods. You'll need reflect-metadata installed and experimentalDecorators: true in your tsconfig.json. Here's a minimal implementation:
// src/decorators/controller.ts
import 'reflect-metadata';
import { RequestHandler, Router } from 'express';
// Metadata keys
const PATH_METADATA = 'path';
const METHOD_METADATA = 'method';
const MIDDLEWARE_METADATA = 'middleware';
// HTTP method decorators
export const Get = (path: string): MethodDecorator => {
return (target, propertyKey) => {
Reflect.defineMetadata(PATH_METADATA, path, target, propertyKey);
Reflect.defineMetadata(METHOD_METADATA, 'get', target, propertyKey);
};
};
export const Post = (path: string): MethodDecorator => {
return (target, propertyKey) => {
Reflect.defineMetadata(PATH_METADATA, path, target, propertyKey);
Reflect.defineMetadata(METHOD_METADATA, 'post', target, propertyKey);
};
};
// Middleware decorator
export const Use = (middleware: RequestHandler): MethodDecorator => {
return (target, propertyKey) => {
const middlewares = Reflect.getMetadata(MIDDLEWARE_METADATA, target, propertyKey) || [];
Reflect.defineMetadata(
MIDDLEWARE_METADATA,
[...middlewares, middleware],
target,
propertyKey
);
};
};
// Controller decorator
export const Controller = (basePath: string): ClassDecorator => {
return (target) => {
Reflect.defineMetadata(PATH_METADATA, basePath, target);
// Return the enhanced constructor
return target;
};
};
// Helper to create router from controller
export const createControllerRouter = (controllerClass: any): Router => {
const router = Router();
const controllerInstance = new controllerClass();
const basePath = Reflect.getMetadata(PATH_METADATA, controllerClass) || '';
// Get all method names from the prototype
const methodNames = Object.getOwnPropertyNames(controllerClass.prototype)
.filter(method => method !== 'constructor');
methodNames.forEach(methodName => {
const routeHandler = controllerInstance[methodName].bind(controllerInstance);
const path = Reflect.getMetadata(PATH_METADATA, controllerClass.prototype, methodName) || '';
const method = Reflect.getMetadata(METHOD_METADATA, controllerClass.prototype, methodName);
const middlewares = Reflect.getMetadata(MIDDLEWARE_METADATA, controllerClass.prototype, methodName) || [];
if (path && method) {
router[method](path, ...middlewares, routeHandler);
}
});
return router;
};
Now you can use these decorators to define controllers:
// src/controllers/userController.ts
import { Request, Response } from 'express';
import { Controller, Get, Post, Use } from '../decorators/controller';
import authenticate from '../middleware/auth';
@Controller('/users')
export class UserController {
@Get('/')
async getAllUsers(req: Request, res: Response) {
res.json([{ id: 1, name: 'User 1' }, { id: 2, name: 'User 2' }]);
}
@Get('/:id')
async getUserById(req: Request, res: Response) {
const userId = req.params.id;
res.json({ id: userId, name: `User ${userId}` });
}
@Post('/')
@Use(authenticate)
async createUser(req: Request, res: Response) {
const newUser = req.body;
res.status(201).json(newUser);
}
}
Finally, use the controller in your application:
// src/app.ts
import express from 'express';
import { createControllerRouter } from './decorators/controller';
import { UserController } from './controllers/userController';
import errorHandler from './middleware/errorHandler';
const app = express();
app.use(express.json());
// Register controller
app.use(createControllerRouter(UserController));
// Error handling
app.use(errorHandler);
app.listen(3000, () => {
console.log('Server started on port 3000');
});
The result looks a lot like NestJS, but it's just plain Express underneath with a thin decorator layer on top. You control the implementation, which keeps things simpler to debug when something goes wrong.
Connecting a TypeScript Express Server to a Database
TypeORM is the most popular ORM for TypeScript Express apps. It uses decorators to define entities, infers types from your schema, and gives you a repository pattern that keeps database access consistent. Here's a full setup:
// src/config/database.ts
import { DataSource } from 'typeorm';
import { User } from '../entities/User';
import 'dotenv/config';
// TypeORM data source configuration
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'express_ts_db',
synchronize: process.env.NODE_ENV !== 'production', // Auto-create schema in dev
logging: process.env.NODE_ENV !== 'production',
entities: [User],
migrations: ['src/migrations/**/*.ts'],
});
// Initialize database connection
export const initializeDatabase = async () => {
try {
await AppDataSource.initialize();
console.log('Database connection established');
return AppDataSource;
} catch (error) {
console.error('Error connecting to database:', error);
throw error;
}
};
Next, define your entity with TypeScript:
// src/entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false }) // Exclude from default selects
password: string;
@Column({ default: false })
isAdmin: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Create a repository for accessing your data:
// src/repositories/userRepository.ts
import { Repository } from 'typeorm';
import { User } from '../entities/User';
import { AppDataSource } from '../config/database';
export class UserRepository {
private repository: Repository<User>;
constructor() {
this.repository = AppDataSource.getRepository(User);
}
async findAll(): Promise<User[]> {
return this.repository.find();
}
async findById(id: string): Promise<User | null> {
return this.repository.findOneBy({ id });
}
async findByEmail(email: string): Promise<User | null> {
return this.repository.findOneBy({ email });
}
async create(userData: `Partial<User>`): Promise<User> {
const user = this.repository.create(userData);
return this.repository.save(user);
}
async update(id: string, userData: `Partial<User>`): Promise<User | null> {
await this.repository.update(id, userData);
return this.findById(id);
}
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete(id);
return result.affected ? result.affected > 0 : false;
}
}
Now, use the repository in a controller:
// src/controllers/userController.ts
import { Request, Response } from 'express';
import { UserRepository } from '../repositories/userRepository';
import { NotFoundError } from '../types/errors';
export class UserController {
private userRepository: UserRepository;
constructor() {
this.userRepository = new UserRepository();
}
async getAllUsers(req: Request, res: Response) {
const users = await this.userRepository.findAll();
res.json(users);
}
async getUserById(req: Request, res: Response) {
const userId = req.params.id;
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundError(`User with ID ${userId} not found`);
}
res.json(user);
}
async createUser(req: Request, res: Response) {
const userData = req.body;
const newUser = await this.userRepository.create(userData);
res.status(201).json(newUser);
}
async updateUser(req: Request, res: Response) {
const userId = req.params.id;
const userData = req.body;
const updatedUser = await this.userRepository.update(userId, userData);
if (!updatedUser) {
throw new NotFoundError(`User with ID ${userId} not found`);
}
res.json(updatedUser);
}
async deleteUser(req: Request, res: Response) {
const userId = req.params.id;
const deleted = await this.userRepository.delete(userId);
if (!deleted) {
throw new NotFoundError(`User with ID ${userId} not found`);
}
res.status(204).send();
}
}
Finally, integrate the database connection with your Express application:
// src/app.ts
import express from 'express';
import { initializeDatabase } from './config/database';
import userRouter from './routes/user';
import errorHandler from './middleware/errorHandler';
const startServer = async () => {
// Initialize the database
await initializeDatabase();
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(express.json());
// Routes
app.use('/users', userRouter);
// Error handling
app.use(errorHandler);
// Start the server
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});
};
// Handle any errors during startup
startServer().catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});
TypeORM's repository pattern keeps your database logic out of route handlers and makes each operation independently testable. If you'd rather skip the ORM layer entirely, Convex's database functions give you typed queries with no schema configuration required.
Where Developers Get Stuck with Express TypeScript
Here are the issues that come up most often, with fixes for each.
Typing req.body as any
The most common shortcut that defeats the purpose of using TypeScript with Express. Instead of:
app.post('/users', (req: Request, res: Response) => {
const user = req.body as any; // gives up all type safety
});
Use typed generics or Zod validation as shown above:
app.post('/users', (req: Request<{}, {}, CreateUserInput>, res: Response) => {
const user = req.body; // typed as CreateUserInput
});
Extending Request without module augmentation
Creating a custom interface that extends Request works inside a single file, but it won't be recognized globally. To add properties like req.user across your entire app, use declaration merging:
// src/types/express.d.ts
import { User } from '../entities/User';
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
With this in place, any middleware that sets req.user will have the correct type available in all downstream handlers, without re-extending Request in every file.
Forgetting express.json() middleware
If req.body is always undefined, you've probably forgotten to add app.use(express.json()) before your routes. TypeScript won't warn you about this, and the resulting behavior can be confusing to debug.
Async errors silently swallowed in Express 4
In Express 4, if you throw inside an async route handler, the error won't reach your error middleware. It either hangs or crashes the process. You need to call next(error) explicitly or switch to Express 5, where this is handled automatically (as covered above).
Generic parameter mismatches on Request<>
With strict: true enabled, you may hit type errors when using Express's Request generics. The full signature is Request<Params, ResBody, ReqBody, Query>. If you only need to type the body, pass empty objects for the others: Request<{}, {}, CreateUserInput, {}>. Leaving them out entirely can cause unexpected inference issues in strict mode.
Final Thoughts on Express with TypeScript
Express doesn't enforce TypeScript discipline for you, which means you have to bring it yourself. But when you set it up deliberately, you get a server framework with genuine end-to-end type safety: typed routes, typed middleware, typed request bodies, and typed error handling.
A few rules to keep in mind:
- Always use
strict: truein yourtsconfig.json. The occasional extra annotation is worth catching bugs at compile time. - Don't type
req.bodyasany. Use typed generics or Zod schemas to ensure runtime shape matches your types. - Use module augmentation (
express.d.ts) to extendRequestglobally rather than creating per-file interfaces. - Upgrade to Express 5 for new projects. Native async error propagation removes an entire category of runtime bugs.
- Keep your error class hierarchy typed. A
statusCodeproperty on your base error class makes the error handler much cleaner.
If you're building a new project and want TypeScript support built in from the start, Fastify with TypeScript is worth a look as an alternative. For projects where type safety needs to reach all the way to the database layer, Convex's database functions offer end-to-end inference with minimal configuration.