Building a REST API with Express and TypeScript

Why TypeScript for APIs?

Building APIs with Express is great, but add TypeScript and it gets even better. You get type safety, better IDE support, and catch errors before they hit production. Let's build a fully typed REST API with proper error handling and validation.

Interactive Demo

Before we dive in, try out this interactive demo. It simulates the API we'll build, letting you test different HTTP methods and see how they work:

API Tester

Try different HTTP methods and endpoints to see how a REST API works. This is a simulated API for demonstration.

Setting Up the Project

First, let's create a new project and install our dependencies:

mkdir express-ts-api
cd express-ts-api
npm init -y
npm install express zod
npm install --save-dev typescript @types/express @types/node ts-node-dev

Configure TypeScript by creating a tsconfig.json:

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Project Structure

Let's organize our code properly:

src/
  ├── types/       # Type definitions
  ├── middleware/  # Custom middleware
  ├── routes/      # Route handlers
  ├── validators/  # Request validation
  └── index.ts     # Main app file

Type Definitions

Create src/types/user.ts to define our data types:

export interface User {
  id: number;
  name: string;
  email: string;
}

export interface CreateUserDto {
  name: string;
  email: string;
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
}

Request Validation

We'll use Zod for request validation. Create src/validators/user.ts:

import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
});

export const updateUserSchema = createUserSchema.partial();

// Type inference
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

Error Handling Middleware

Create src/middleware/error.ts to handle different types of errors:

import { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';

export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  if (err instanceof ZodError) {
    return res.status(400).json({
      error: 'Validation error',
      details: err.errors,
    });
  }

  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
};

Route Handlers

Create src/routes/users.ts for our user routes:

import { Router } from 'express';
import { createUserSchema, updateUserSchema } from '../validators/user';
import { User } from '../types/user';

const router = Router();

// In-memory database
let users: User[] = [];
let nextId = 1;

router.get('/', (req, res) => {
  res.json(users);
});

router.get('/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

router.post('/', async (req, res, next) => {
  try {
    const validatedData = createUserSchema.parse(req.body);
    const newUser: User = {
      id: nextId++,
      ...validatedData,
    };
    users.push(newUser);
    res.status(201).json(newUser);
  } catch (error) {
    next(error);
  }
});

router.put('/:id', async (req, res, next) => {
  try {
    const validatedData = updateUserSchema.parse(req.body);
    const userId = parseInt(req.params.id);
    const userIndex = users.findIndex(u => u.id === userId);
    
    if (userIndex === -1) {
      return res.status(404).json({ error: 'User not found' });
    }
    
    users[userIndex] = {
      ...users[userIndex],
      ...validatedData,
    };
    
    res.json(users[userIndex]);
  } catch (error) {
    next(error);
  }
});

router.delete('/:id', (req, res) => {
  const userId = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === userId);
  
  if (userIndex === -1) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  users = users.filter(u => u.id !== userId);
  res.status(204).send();
});

export default router;

Putting It All Together

Finally, create src/index.ts to set up our Express app:

import express from 'express';
import userRoutes from './routes/users';
import { errorHandler } from './middleware/error';

const app = express();

app.use(express.json());
app.use('/api/users', userRoutes);
app.use(errorHandler);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Testing the API

Add these scripts to your package.json:

{
  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Now you can test your API with curl or Postman:

# Create a user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "john@example.com"}'

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

# Update a user
curl -X PUT http://localhost:3000/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "John Smith"}'

# Delete a user
curl -X DELETE http://localhost:3000/api/users/1

Best Practices

1. Error Handling

  • Use custom error classes for different types of errors
  • Always validate input data
  • Return consistent error responses
  • Log errors properly

2. Type Safety

  • Use TypeScript's strict mode
  • Define interfaces for all data structures
  • Use type inference where possible
  • Validate runtime data against your types

3. API Design

  • Use proper HTTP methods and status codes
  • Version your API
  • Use meaningful route names
  • Document your API

Next Steps

To make this API production-ready, consider adding:

  • Authentication and authorization
  • Rate limiting
  • Request logging
  • Database integration
  • API documentation (e.g., with Swagger)
  • Environment configuration
  • Tests

Pro Tip: Consider using a dependency injection container like tsyringe for better testability and maintainability in larger applications.