TypeScript with Express
Express is the most popular Node.js web framework, and TypeScript makes it significantly more robust. With typed request/response objects, validated route params, and strongly-typed middleware, you catch entire classes of API bugs at compile time rather than in production.
This page walks through a complete, real-world Express + TypeScript setup.
Installation
npm install express npm install --save-dev typescript ts-node @types/node @types/express ts-node-dev
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Basic Server Setup
// src/index.ts
import express, { Application, Request, Response, NextFunction } from 'express';
const app: Application = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
export default app;Typing Request Bodies, Params, and Query
Request is a generic type: Request<Params, ResBody, ReqBody, Query>. Type each position to get full safety:
import { Request, Response } from 'express';
// Define interfaces for each part of the request
interface CreateUserBody {
name: string;
email: string;
role?: 'admin' | 'user';
}
interface UserParams {
id: string;
}
interface UserQuery {
include?: 'posts' | 'orders';
limit?: string; // query params are always strings
}
// POST /users — typed body
app.post(
'/users',
async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email, role = 'user' } = req.body;
// name, email, role are fully typed — no any
const user = await UserService.create({ name, email, role });
res.status(201).json(user);
}
);
// GET /users/:id — typed params and query
app.get(
'/users/:id',
async (req: Request<UserParams, {}, {}, UserQuery>, res: Response) => {
const { id } = req.params; // string
const { include } = req.query; // 'posts' | 'orders' | undefined
const user = await UserService.findById(id, include);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
}
);string | undefined at runtime. Parse numbers with parseInt inside the handler or use a validation library.Typed Middleware
Middleware is typed with RequestHandler or by annotating the standard (req, res, next) signature. You can extend Request with custom properties using declaration merging:
// src/types/express.d.ts — extend the Request type globally
import { JwtPayload } from 'jsonwebtoken';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: 'admin' | 'user';
};
}
}
}// src/middleware/auth.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
export const authenticate: RequestHandler = (
req: Request,
res: Response,
next: NextFunction
): void => {
const header = req.headers['authorization'];
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing token' });
return;
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env['JWT_SECRET']!) as {
id: string; email: string; role: 'admin' | 'user';
};
req.user = payload; // now available on req.user everywhere
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
// Protected route — req.user is available after authenticate runs
app.get('/profile', authenticate, (req: Request, res: Response) => {
res.json(req.user); // { id, email, role }
});Typed Error Handling Middleware
Express error handlers take four parameters. TypeScript recognises this signature and types them accordingly:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
class AppError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code?: string
) {
super(message);
this.name = 'AppError';
}
}
const errorHandler: ErrorRequestHandler = (
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void => {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
code: err.code,
status: err.statusCode,
});
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message, status: 500 });
return;
}
res.status(500).json({ error: 'Unknown error', status: 500 });
};
// Must be the LAST middleware registered
app.use(errorHandler);
// Usage in routes — throw AppError to send structured error responses
app.get('/users/:id', async (req, res, next) => {
try {
const user = await UserService.findById(req.params.id);
if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
res.json(user);
} catch (err) {
next(err); // delegate to errorHandler
}
});Router Organisation
// src/routes/users.router.ts
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../middleware/auth';
const router = Router();
router.get('/', authenticate, listUsers);
router.post('/', createUser);
router.get('/:id', authenticate, getUser);
router.put('/:id', authenticate, updateUser);
router.delete('/:id', authenticate, deleteUser);
async function listUsers(_req: Request, res: Response, next: NextFunction) {
try {
const users = await UserService.findAll();
res.json(users);
} catch (err) { next(err); }
}
async function createUser(req: Request<{}, {}, CreateUserBody>, res: Response, next: NextFunction) {
try {
const user = await UserService.create(req.body);
res.status(201).json(user);
} catch (err) { next(err); }
}
async function getUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const user = await UserService.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) { next(err); }
}
export { router as usersRouter };
// src/index.ts
import { usersRouter } from './routes/users.router';
app.use('/api/users', usersRouter);Validation with Zod
Zod is the ideal companion for Express — it validates request bodies at runtime and infers TypeScript types from the schema:
npm install zod
import { z } from 'zod';
import { Request, Response, NextFunction, RequestHandler } from 'express';
// Define schema — infer TypeScript type from it
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user']).default('user'),
});
type CreateUserDto = z.infer<typeof CreateUserSchema>;
// Generic middleware factory that validates the request body
function validateBody<T extends z.ZodTypeAny>(schema: T): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: 'Validation failed',
issues: result.error.flatten().fieldErrors,
});
return;
}
req.body = result.data; // replace with validated/transformed data
next();
};
}
// Use in routes
app.post(
'/users',
validateBody(CreateUserSchema),
async (req: Request<{}, {}, CreateUserDto>, res: Response, next: NextFunction) => {
try {
const user = await UserService.create(req.body);
res.status(201).json(user);
} catch (err) { next(err); }
}
);Async Handler Wrapper
Express does not catch Promise rejections from async route handlers by default (in versions before Express 5). A tiny wrapper fixes this:
import { Request, Response, NextFunction, RequestHandler } from 'express';
// Wrap an async handler and forward errors to next()
function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
// Much cleaner routes — no try/catch needed
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await UserService.findById(req.params.id);
if (!user) throw new AppError(404, 'User not found');
res.json(user);
}));
app.post('/users', validateBody(CreateUserSchema), asyncHandler(async (req, res) => {
const user = await UserService.create(req.body as CreateUserDto);
res.status(201).json(user);
}));Complete Project Structure
src/
index.ts # App entry — creates and starts server
app.ts # Creates Express app, registers middleware/routes
config.ts # Typed environment config
types/
express.d.ts # Augmented Express.Request
index.ts # Shared interfaces (User, Order, etc.)
routes/
users.router.ts
orders.router.ts
middleware/
auth.ts # JWT authentication
errorHandler.ts # Global error handler
validateBody.ts # Zod validation factory
services/
user.service.ts # Business logic
order.service.ts
models/
user.model.ts # DB model (TypeORM/Prisma/etc.)
utils/
asyncHandler.ts
logger.tsKey Middleware Typing Reference
Type | Signature | Use for |
|---|---|---|
RequestHandler | (req, res, next) => void | Standard middleware and route handlers |
ErrorRequestHandler | (err, req, res, next) => void | Error handling middleware (4 params) |
Request<P,RB,RQ,Q> | Generic request | Typed params, response body, request body, query |
Response<RB> | Generic response | Typed response body for res.json() |
NextFunction | (err?: unknown) => void | Call next() or next(err) |
Key Takeaways
Install @types/express for full TypeScript support across Request, Response, and Router
Type Request generics: Request<Params, ResBody, ReqBody, Query> for full safety
Extend Express.Request via declaration merging to add custom properties like req.user
Error handlers need all four parameters (err, req, res, next) to be recognised by Express
Create an asyncHandler wrapper to forward async errors to Express error handlers
Zod schemas validate at runtime and infer TypeScript types — no duplication needed
Organise routes into Router files and mount them in the main app
Never use any for req.body — always type it through Request<{}, {}, MyBodyType>