Menu

Building Scalable APIs with Node.js and TypeScript
Backend Development

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.

J

Jane Smith

Author

12 min read
#Node.js#TypeScript#API

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.