Building Scalable APIs with Node.js and TypeScript
Learn how to create robust, type-safe APIs using Node.js and TypeScript. We'll cover best practices, error handling, and testing strategies.
Jane Smith
Author
Building Scalable APIs with Node.js and TypeScript
Creating robust, maintainable APIs is crucial for modern web applications. In this comprehensive guide, we'll explore how to build scalable APIs using Node.js and TypeScript, covering everything from project setup to deployment.
Why TypeScript for APIs?
TypeScript brings several advantages to API development:
- Type Safety: Catch errors at compile time
- Better IDE Support: Enhanced autocomplete and refactoring
- Self-Documenting Code: Types serve as documentation
- Easier Refactoring: Confident code changes with type checking
Project Setup
Let's start by setting up a new TypeScript Node.js project:
mkdir scalable-api
cd scalable-api
npm init -y
npm install express cors helmet morgan
npm install -D typescript @types/node @types/express ts-node nodemon
TypeScript Configuration
Create a tsconfig.json
file:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
API Architecture
Folder Structure
src/
controllers/
middleware/
models/
routes/
services/
types/
utils/
app.ts
server.ts
Type Definitions
Start by defining your API types:
// src/types/user.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
}
Express App Setup
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler';
import userRoutes from './routes/userRoutes';
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/users', userRoutes);
// Error handling
app.use(errorHandler);
export default app;
Controllers and Services
Service Layer
// src/services/userService.ts
import { User, CreateUserRequest, UpdateUserRequest } from '../types/user';
export class UserService {
async createUser(userData: CreateUserRequest): Promise<User> {
// Implementation here
const user: User = {
id: generateId(),
email: userData.email,
name: userData.name,
createdAt: new Date(),
updatedAt: new Date(),
};
return user;
}
async getUserById(id: string): Promise<User | null> {
// Implementation here
return null;
}
async updateUser(id: string, updates: UpdateUserRequest): Promise<User | null> {
// Implementation here
return null;
}
async deleteUser(id: string): Promise<boolean> {
// Implementation here
return true;
}
}
Controller Layer
// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/userService';
import { CreateUserRequest, UpdateUserRequest } from '../types/user';
export class UserController {
private userService: UserService;
constructor() {
this.userService = new UserService();
}
createUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const userData: CreateUserRequest = req.body;
const user = await this.userService.createUser(userData);
res.status(201).json({ success: true, data: user });
} catch (error) {
next(error);
}
};
getUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const user = await this.userService.getUserById(id);
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
};
}
Error Handling
Custom Error Classes
// src/utils/errors.ts
export class AppError extends Error {
public statusCode: number;
public isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string = 'Resource not found') {
super(message, 404);
}
}
Error Handler Middleware
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (error instanceof AppError) {
return res.status(error.statusCode).json({
success: false,
message: error.message,
});
}
// Log unexpected errors
console.error('Unexpected error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
};
Validation and Middleware
Request Validation
// src/middleware/validation.ts
import { Request, Response, NextFunction } from 'express';
import { ValidationError } from '../utils/errors';
export const validateCreateUser = (req: Request, res: Response, next: NextFunction) => {
const { email, name, password } = req.body;
if (!email || !name || !password) {
throw new ValidationError('Email, name, and password are required');
}
if (!isValidEmail(email)) {
throw new ValidationError('Invalid email format');
}
if (password.length < 8) {
throw new ValidationError('Password must be at least 8 characters long');
}
next();
};
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
Testing
Unit Tests
// src/tests/userService.test.ts
import { UserService } from '../services/userService';
import { CreateUserRequest } from '../types/user';
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
describe('createUser', () => {
it('should create a user successfully', async () => {
const userData: CreateUserRequest = {
email: 'test@example.com',
name: 'Test User',
password: 'password123',
};
const user = await userService.createUser(userData);
expect(user).toBeDefined();
expect(user.email).toBe(userData.email);
expect(user.name).toBe(userData.name);
});
});
});
Best Practices
1. Use Environment Variables
// src/config/env.ts
export const config = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
dbUrl: process.env.DATABASE_URL || 'mongodb://localhost:27017/myapp',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
};
2. Implement Logging
// src/utils/logger.ts
import winston from 'winston';
export const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
3. Rate Limiting
// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
});
Conclusion
Building scalable APIs with Node.js and TypeScript requires careful planning and adherence to best practices. By implementing proper error handling, validation, testing, and following architectural patterns, you can create robust APIs that are maintainable and scalable.
The combination of TypeScript's type safety and Node.js's performance makes for a powerful backend development stack that can handle complex applications with confidence.