Skip to main content

How to use Express with TypeScript to make apps easier to build and maintain

Combining Express with TypeScript can significantly improve the development of web applications by making them easier to build and maintain. TypeScript enums, for example, allow you to define a set of named constants, enhancing the readability and safety of your code. They help organize related values like roles or states clearly, instead of scattering random strings or numbers throughout your codebase.

Setting Up an Express Server with TypeScript

To set up an Express server using TypeScript, follow these steps:

  1. 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
  1. Initialize a TypeScript project and configure the tsconfig.json:
npx tsc --init
  1. Update your tsconfig.json to work with Express:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
  1. 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}`);
});
  1. Add scripts to your package.json for development and building:
"scripts": {
"dev": "nodemon src/app.ts",
"build": "tsc",
"start": "node build/app.js"
}

This setup provides you with a development environment that supports hot reloading with npm run dev and production builds with npm run build && npm start.

Handling Routes in Express with TypeScript

Use TypeScript to define route parameters and request/response types in Express, ensuring your routes are well-typed and organized. This helps maintain the structure and safety of complex route setups.

// 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}`);
});

This approach leverages interfaces to define the shape of your data and provides type safety across your route handlers.

Integrating Middleware in an Express App with TypeScript

When using TypeScript, ensure that middleware functions have the correct type signatures. Type annotations help keep your custom middleware easy to read and maintain.

// 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.

Implementing Error Handling in Express using TypeScript

To handle errors in Express with TypeScript, create custom error handlers with type annotations. Error handling middleware processes errors in a structured way, making your application more reliable.

// 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');
});

This approach leverages classes to create an error hierarchy that's both type-safe and expressive. It ensures consistent error responses across your application while providing detailed information about what went wrong.

Using Decorators in Express with TypeScript

Decorators in Express allow you to simplify your code by creating custom decorators for route handlers. They can, for instance, ensure users are authenticated before accessing specific routes. Here's some example decorators:

// 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');
});

This pattern leverages decorators to create a clean, declarative API for defining routes and applying middleware in your Express application. It's similar to how frameworks like NestJS work, but customized for your Express application.

Connecting a TypeScript Express Server to a Database

To connect a TypeScript Express server to a database, use an ORM like TypeORM for type-safe interactions. This ensures your database operations are efficient and maintainable.

// 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);
});

This implementation uses classes and decorators to define entities and repositories. It also demonstrates how to leverage TypeORM's type-safe queries with TypeScript.

For projects requiring simpler database access, you could alternatively use Convex's database functions which provide end-to-end type safety with minimal configuration.

Final Thoughts about Express & TypeScript

Combining Express with TypeScript offers significant advantages for building web servers. TypeScript's static typing catches errors early in development, improves code maintainability, and enhances IDE support with better autocompletion and refactoring tools. The examples shown here demonstrate how to leverage TypeScript features like interfaces, generics, decorators, and classes to create robust Express applications. By typing request and response objects, implementing middleware with proper type signatures, and creating structured error handling, you can build more reliable and maintainable APIs.