Skip to main content

Express + best practices

In this chapter, you learn type-safe backend development that combines Express and TypeScript.

What you learn in this chapter

  • Setting up Express + TypeScript
  • Type-safe requests/responses
  • Error handling
  • Validation (Zod)
  • Best practices for type design
info

Using TypeScript with Express improves the type safety of your API and lets you clearly define the structure of requests and responses.

Setting up Express + TypeScript

This book assumes Express 5 (GA in September 2024). Express 5 includes several important improvements, such as automatically propagating errors from async functions to next, stricter path-to-regexp syntax, and dropping require() (ESM recommended).

# Create the project directory
mkdir express-api
cd express-api
npm init -y

# Install the dependencies (specify Express 5 explicitly)
npm install express@^5 cors helmet
npm install --save-dev typescript @types/node @types/express @types/cors tsx
info

For running during development, use tsx, which is ESM-compatible and fast. The older ts-node-dev is mainly CommonJS and tends to be unstable in ESM projects. On Node.js 22+, you can also use node --watch --experimental-strip-types src/index.ts as an alternative.

package.json (specify "type": "module"):

{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}

tsconfig.json (ESM + NodeNext recommended):

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
info

For a CommonJS project

If you want to keep an existing CommonJS project, set "module": "commonjs" / "moduleResolution": "node" and remove "type": "module" from package.json. However, for a new 2026 project, choosing ESM ("type": "module" + "module": "NodeNext") along with Express 5 is recommended.

The basic structure of an Express application

// src/index.ts
// Import Express and the related types
import express, { Application, Request, Response, NextFunction } from 'express';
import cors from 'cors'; // CORS middleware
import helmet from 'helmet'; // sets security headers
import { userRouter } from './routes/userRoutes';
import { errorHandler } from './middleware/errorHandler';

// Create the Express application
const app: Application = express();

// The port number (environment variable or 3000)
const PORT = process.env.PORT || 3000;

// ===== Configure the middleware =====
app.use(helmet()); // set the security headers
app.use(cors()); // allow CORS (cross-origin)
app.use(express.json()); // parse the JSON body

// ===== Configure the routes =====
// The root path
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Welcome to the API' });
});

// User-related routes (/api/users/*)
app.use('/api/users', userRouter);

// ===== Error handling (place it last) =====
app.use(errorHandler);

// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

Type-safe requests/responses

First, define the types you will use.

// src/types/index.ts

// The user type
export interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}

// The DTO (Data Transfer Object) for creating a user
// id and createdAt are not needed at creation time
export interface CreateUserDto {
name: string;
email: string;
}

// The DTO for updating a user
// All fields are optional
export interface UpdateUserDto {
name?: string;
email?: string;
}

// The API response type (generic)
// On success it includes data, on failure it includes error
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}

Next, create type-safe routes.

// src/routes/userRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { User, CreateUserDto, UpdateUserDto, ApiResponse } from '../types';

const router = Router();

// An in-memory data store (use a DB in reality)
let users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', createdAt: new Date() },
{ id: 2, name: 'Bob', email: 'bob@example.com', createdAt: new Date() },
];
let nextId = 3;

// Define the parameter type
interface UserParams {
id: string; // URL parameters are always strings
}

// ===== GET /api/users - get all users =====
// Specify the response type with Response<ApiResponse<User[]>>
router.get('/', (req: Request, res: Response<ApiResponse<User[]>>) => {
res.json({
success: true,
data: users,
});
});

// ===== GET /api/users/:id - get a specific user =====
// Specify the parameter type with Request<UserParams>
router.get('/:id', (
req: Request<UserParams>,
res: Response<ApiResponse<User>>
) => {
// Convert the parameter to a number
const id = parseInt(req.params.id, 10);
// Find the user
const user = users.find(u => u.id === id);

if (!user) {
// 404 error
return res.status(404).json({
success: false,
error: 'User not found',
});
}

res.json({
success: true,
data: user,
});
});

// ===== POST /api/users - create a user =====
// Specify the types in the order Request<Params, ResBody, ReqBody>
router.post('/', (
req: Request<{}, ApiResponse<User>, CreateUserDto>,
res: Response<ApiResponse<User>>
) => {
const { name, email } = req.body;

// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: 'Name and email are required',
});
}

// Create a new user
const newUser: User = {
id: nextId++,
name,
email,
createdAt: new Date(),
};

users.push(newUser);

// 201 Created
res.status(201).json({
success: true,
data: newUser,
message: 'User created successfully',
});
});

// ===== PUT /api/users/:id - update a user =====
router.put('/:id', (
req: Request<UserParams, ApiResponse<User>, UpdateUserDto>,
res: Response<ApiResponse<User>>
) => {
const id = parseInt(req.params.id, 10);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return res.status(404).json({
success: false,
error: 'User not found',
});
}

const { name, email } = req.body;

// Update only the fields that exist
if (name) users[userIndex].name = name;
if (email) users[userIndex].email = email;

res.json({
success: true,
data: users[userIndex],
message: 'User updated successfully',
});
});

// ===== DELETE /api/users/:id - delete a user =====
router.delete('/:id', (
req: Request<UserParams>,
res: Response<ApiResponse<null>>
) => {
const id = parseInt(req.params.id, 10);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return res.status(404).json({
success: false,
error: 'User not found',
});
}

// Remove it from the array
users.splice(userIndex, 1);

res.json({
success: true,
message: 'User deleted successfully',
});
});

export { router as userRouter };

Error handling

Create a custom error class and an error handling middleware.

info

In Express 5, async errors are passed to next automatically

In Express 4, an exception thrown inside an async function was not caught by Express, and you needed manual try/catch + next(err). From Express 5 (GA in 2024), a rejected Promise returned by an async function automatically propagates to the error middleware. As a result, the following verbose try/catch is no longer needed.

// The way you had to write it in Express 4
app.get('/users/:id', async (req, res, next) => {
try {
const user = await fetchUser(req.params.id)
res.json(user)
} catch (err) {
next(err) // required
}
})

// In Express 5, you can write it straightforwardly
app.get('/users/:id', async (req, res) => {
const user = await fetchUser(req.params.id)
res.json(user) // the exception automatically flows to errorHandler
})

It is still OK to use try/catch for reasons like formatting the error message. Use the intentional next(err) and the automatic propagation as appropriate.

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ApiResponse } from '../types';

// A custom error class
export class AppError extends Error {
statusCode: number; // the HTTP status code
isOperational: boolean; // whether it is an operational error

constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // an error the application anticipates

// Capture the stack trace correctly
Error.captureStackTrace(this, this.constructor);
}
}

// The error handling middleware
// A function with four arguments is recognized as an error handler in Express
export function errorHandler(
err: Error | AppError,
req: Request,
res: Response<ApiResponse<null>>,
next: NextFunction
): void {
console.error('Error:', err);

// For an AppError, return the appropriate status code
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
error: err.message,
});
return;
}

// An unexpected error (500 Internal Server Error)
res.status(500).json({
success: false,
error: 'Internal server error',
});
}

// A wrapper that catches async errors
// Automatically passes an async function's error to next()
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return (req: Request, res: Response, next: NextFunction) => {
// Catch the Promise's error and pass it to next
Promise.resolve(fn(req, res, next)).catch(next);
};
}

Usage:

// Use asyncHandler and AppError
import { asyncHandler, AppError } from '../middleware/errorHandler';

router.get('/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const user = await userService.findById(id);

if (!user) {
// Throwing an AppError lets errorHandler catch and handle it
throw new AppError('User not found', 404);
}

res.json({ success: true, data: user });
}));

Validation (Zod)

Implement type-safe validation using Zod.

npm install zod
// src/validation/userValidation.ts
import { z } from 'zod';

// Define the schema
// Create an object schema with z.object()
export const createUserSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters') // minimum 2 characters
.max(50, 'Name must be at most 50 characters'), // maximum 50 characters
email: z.email('Invalid email format'), // check the email format (a top-level API in v4)
});

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

// Generate a type from the schema
// z.infer<typeof schema> extracts a type from the schema
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

The validation middleware:

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
import { ApiResponse } from '../types';

// A generic validation middleware
export function validate(schema: ZodSchema) {
return (req: Request, res: Response<ApiResponse<null>>, next: NextFunction) => {
// safeParse: does not throw an error; returns a result object
const result = schema.safeParse(req.body);

if (!result.success) {
// A validation error
const errors = result.error.issues.map(e => ({
field: e.path.join('.'), // the error's field path
message: e.message, // the error message
}));

return res.status(400).json({
success: false,
error: 'Validation failed',
message: JSON.stringify(errors),
});
}

// Replace with the validated data
req.body = result.data;
next();
};
}

Using it in a route:

// src/routes/userRoutes.ts
import { validate } from '../middleware/validate';
import { createUserSchema } from '../validation/userValidation';

// Add the validate middleware to the route
router.post('/', validate(createUserSchema), (req, res) => {
// req.body can be used safely as the CreateUserInput type
const { name, email } = req.body;
// ...
});

Best practices for type design

1. The DTO pattern

// Define an appropriate type for each request/response

// The entity (the database shape)
interface User {
id: number;
name: string;
email: string;
passwordHash: string; // used internally only
createdAt: Date;
updatedAt: Date;
}

// The DTO for creation (only the necessary fields)
interface CreateUserDto {
name: string;
email: string;
password: string; // the password (before hashing)
}

// The DTO for the response (excludes the password)
interface UserResponseDto {
id: number;
name: string;
email: string;
createdAt: Date;
}

// Convert an entity for the response
function toUserResponse(user: User): UserResponseDto {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
};
}

2. A unified API response

// Use a unified format across all APIs
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
meta?: {
total?: number;
page?: number;
limit?: number;
};
}

// A helper to create a success response
function successResponse<T>(data: T, message?: string): ApiResponse<T> {
return {
success: true,
data,
message,
};
}

// A helper to create an error response
function errorResponse(error: string, message?: string): ApiResponse<null> {
return {
success: false,
error,
message,
};
}

3. Type guards

// Guarantee the type at runtime too with a type guard function

// Check whether an unknown is a User
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
);
}

// Usage
const data: unknown = await fetchData();
if (isUser(data)) {
// Here, data can be treated as the User type
console.log(data.name);
}

4. Typing environment variables

// src/config/env.ts
import { z } from 'zod';

// The schema for environment variables
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number), // convert a string to a number
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32),
});

// Parse and type the environment variables
export const env = envSchema.parse(process.env);

// env.PORT is the number type
// env.DATABASE_URL is the string type

5. Typing the service layer

// src/services/userService.ts

// The service's return type
interface ServiceResult<T> {
success: boolean;
data?: T;
error?: string;
}

class UserService {
// Find a user
async findById(id: number): Promise<ServiceResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return { success: false, error: 'User not found' };
}
return { success: true, data: user };
} catch (error) {
return { success: false, error: 'Database error' };
}
}

// Create a user
async create(dto: CreateUserDto): Promise<ServiceResult<User>> {
try {
const user = await db.users.create({ data: dto });
return { success: true, data: user };
} catch (error) {
return { success: false, error: 'Failed to create user' };
}
}
}

Try it: build a type-safe API endpoint ★★★

Create a Todo API that meets the following requirements.

Requirements:

  1. GET /api/todos - get all todos
  2. POST /api/todos - create a todo (title required)
  3. PATCH /api/todos/:id/toggle - toggle the completed state
  4. Validate with Zod
  5. Error handling
Hint
  1. Define a Todo type (id, title, completed, createdAt)
  2. Create CreateTodoSchema with Zod
  3. Use the validate middleware
  4. Catch async errors with asyncHandler
  5. Return appropriate errors with AppError
Answer and explanation
// src/types/todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}

export interface CreateTodoDto {
title: string;
}

export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
// src/validation/todoValidation.ts
import { z } from 'zod';

// Title required, 1-100 characters
export const createTodoSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be at most 100 characters'),
});

export type CreateTodoInput = z.infer<typeof createTodoSchema>;
// src/routes/todoRoutes.ts
import { Router, Request, Response } from 'express';
import { Todo, CreateTodoDto, ApiResponse } from '../types/todo';
import { asyncHandler, AppError } from '../middleware/errorHandler';
import { validate } from '../middleware/validate';
import { createTodoSchema } from '../validation/todoValidation';

const router = Router();

// An in-memory data store
let todos: Todo[] = [];
let nextId = 1;

// The parameter type
interface TodoParams {
id: string;
}

// GET /api/todos - get all todos
router.get('/', (req: Request, res: Response<ApiResponse<Todo[]>>) => {
res.json({
success: true,
data: todos,
});
});

// POST /api/todos - create a todo
router.post('/',
// The validation middleware
validate(createTodoSchema),
(req: Request<{}, ApiResponse<Todo>, CreateTodoDto>, res: Response<ApiResponse<Todo>>) => {
const { title } = req.body;

const newTodo: Todo = {
id: nextId++,
title,
completed: false,
createdAt: new Date(),
};

todos.push(newTodo);

res.status(201).json({
success: true,
data: newTodo,
message: 'Todo created successfully',
});
}
);

// PATCH /api/todos/:id/toggle - toggle the completed state
router.patch('/:id/toggle',
asyncHandler(async (req: Request<TodoParams>, res: Response<ApiResponse<Todo>>) => {
const id = parseInt(req.params.id, 10);

// Check for an invalid ID
if (isNaN(id)) {
throw new AppError('Invalid todo ID', 400);
}

const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
throw new AppError('Todo not found', 404);
}

// Flip the completed state
todos[todoIndex].completed = !todos[todoIndex].completed;

res.json({
success: true,
data: todos[todoIndex],
message: `Todo marked as ${todos[todoIndex].completed ? 'completed' : 'incomplete'}`,
});
})
);

// DELETE /api/todos/:id - delete a todo
router.delete('/:id',
asyncHandler(async (req: Request<TodoParams>, res: Response<ApiResponse<null>>) => {
const id = parseInt(req.params.id, 10);

if (isNaN(id)) {
throw new AppError('Invalid todo ID', 400);
}

const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
throw new AppError('Todo not found', 404);
}

todos.splice(todoIndex, 1);

res.json({
success: true,
message: 'Todo deleted successfully',
});
})
);

export { router as todoRouter };
// src/index.ts (add the route)
import { todoRouter } from './routes/todoRoutes';

// ...
app.use('/api/todos', todoRouter);
// ...

Explanation:

  • Type definitions: the Todo interface defines the structure of a todo, and CreateTodoDto defines the input at creation time
  • Zod validation: createTodoSchema sets the title's required check and length limit
  • The validate middleware: adding it to a route runs validation before the handler is reached
  • asyncHandler: automatically catches errors in async processing and passes them to the error handler
  • AppError: returns errors with an appropriate HTTP status code and message
  • Type-safe responses: Response<ApiResponse<T>> guarantees a unified format

How to test:

# Start the server
npm run dev

# Create a todo
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn TypeScript"}'

# Get all todos
curl http://localhost:3000/api/todos

# Toggle the completed state
curl -X PATCH http://localhost:3000/api/todos/1/toggle

# Delete a todo
curl -X DELETE http://localhost:3000/api/todos/1

Alternatives to Express (2026)

Express is still the most widespread Node.js framework, but as of 2026 you can consider the following alternatives depending on the use case.

FrameworkCharacteristicsSuited for
Express 5Stability, track record, a rich ecosystemLeveraging existing Express assets, learning purposes
FastifyFast with an Express-compatible API, schema-drivenA performance-focused REST API
HonoLightweight, based on Web standard APIs, runs in edge environmentsEdge deployment such as Cloudflare Workers / Deno Deploy
ElysiaOptimized for Bun, type inference works wellA new project that adopts Bun
NestJSA decorator-based framework for large-scale appsEnterprise use, DDD / Clean Architecture
info

As a backend for a SPA, Hono has been growing especially popular. It is based on Web standards (Fetch API / Web Streams), so the same code runs on various runtimes like Cloudflare Workers, Deno Deploy, Node.js, and Bun. This book uses Express as the subject, but for new projects, consider choosing based on the use case.

Topics to learn further

After learning the basics in this chapter, the following topics are important in real production development.

ORM (Prisma)

For database operations, the type-safe ORM "Prisma" is popular.

// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}

// Use the auto-generated types
import { PrismaClient, User } from '@prisma/client';

const prisma = new PrismaClient();

// A fully type-safe query
async function getUser(id: number): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}

// Relations are type-safe too
async function getUserWithPosts(id: number) {
return prisma.user.findUnique({
where: { id },
include: { posts: true }, // inferred type
});
}

JWT authentication

For authentication, JWT (JSON Web Token) is often used.

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

// The JWT payload type
interface JwtPayload {
userId: number;
email: string;
}

// The type of an authenticated request
interface AuthRequest extends Request {
user?: JwtPayload;
}

// Generate a token
function generateToken(payload: JwtPayload): string {
return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '24h' });
}

// The authentication middleware
function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
res.status(401).json({ error: 'No token provided' });
return;
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}

Session management

For session-based authentication, use express-session.

import session from 'express-session';

// Extend the session type
declare module 'express-session' {
interface SessionData {
userId: number;
isLoggedIn: boolean;
}
}

app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
}));

// Usage
app.post('/login', (req, res) => {
req.session.userId = 1;
req.session.isLoggedIn = true;
res.json({ message: 'Logged in' });
});

Summary

What you learned in this chapter:

  • Setting up Express + TypeScript: project structure and configuration files
  • Type-safe requests/responses: making use of Request<Params, ResBody, ReqBody>
  • Error handling: the AppError class and the errorHandler middleware
  • asyncHandler: automatically catching async errors
  • Zod validation: defining schemas and generating types
  • The validate middleware: applying validation to routes
  • Best practices for type design: DTOs, unified responses, type guards

In the next chapter, you will create a CLI task management tool as a comprehensive exercise project.

Common pitfalls for beginners

warning

Common mistakes

❌ Getting the order of Request's type parameters wrong

// The order is Request<Params, ResBody, ReqBody, Query>

// ❌ Wrong order
router.post('/', (req: Request<CreateUserDto>) => { // the first argument is Params
// ...
});

// ✅ Correct order
router.post('/', (
req: Request<{}, ApiResponse<User>, CreateUserDto>, // the third argument is ReqBody
res: Response<ApiResponse<User>>
) => {
const { name } = req.body; // the CreateUserDto type
});

Cause: Request's type parameters are in the order <Params, ResBody, ReqBody, Query>. Solution: ReqBody is the third argument, and specify {} for empty parameters.

❌ Async errors are not caught

// ❌ The error of an async route is not passed to Express
router.get('/', async (req, res) => {
const data = await someAsyncOperation(); // if an error occurs...
res.json(data); // crash or hang
});

// ✅ Wrap with asyncHandler
router.get('/', asyncHandler(async (req, res) => {
const data = await someAsyncOperation();
res.json(data);
}));

// The implementation of asyncHandler
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

Cause: Express does not automatically catch errors from async functions. Solution: Wrap with asyncHandler, or use the express-async-errors package.

❌ Forgetting that URL parameters are always strings

// ❌ Using the parameter directly as a number
router.get('/:id', (req, res) => {
const id = req.params.id; // string type
const user = users.find(u => u.id === id); // the comparison may always be false
});

// ✅ Convert it explicitly
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
const user = users.find(u => u.id === id);
});

Cause: URL parameters are always the string type and need to be converted to a number. Solution: Convert with parseInt and validate with isNaN.

❌ Throwing instead of using Zod's safeParse

// ❌ parse throws an error
try {
const data = schema.parse(req.body);
} catch (e) {
// Handling a ZodError is complex
}

// ✅ safeParse returns a result object
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues,
});
}
const data = result.data; // type-safe data

Cause: parse throws an exception on error, making it harder to return a proper error response. Solution: Use safeParse and branch on the success flag.

❌ An error handling middleware with three arguments

// ❌ With three arguments, it is not recognized as an error handler
app.use((req, res, next) => {
// treated as a normal middleware
});

// ✅ With four arguments, it is recognized as an error handler
app.use((err, req, res, next) => { // four arguments are required
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});

Cause: Express recognizes a function with four arguments as an error handling middleware. Solution: Always give it the four arguments (err, req, res, next).