DeveloperBootstrap

TypeScript

TypeScript Node.js REST API Complete Guide

Build production-ready REST APIs with TypeScript, Node.js, Express, PostgreSQL, and Zod validation following enterprise best practices.

What you'll learn

  • Structure a TypeScript Express API with clean architecture
  • Validate all inputs with Zod schemas
  • Integrate PostgreSQL with the pg driver and connection pooling
  • Implement JWT authentication with HttpOnly cookies
  • Write unit and integration tests with Vitest and Supertest
  • Add structured logging with Pino
  • Containerise with Docker and deploy

Project Structure

Bash
1pnpm init
2pnpm add express zod pg pino pino-http jsonwebtoken cookie-parser
3pnpm add -D typescript @types/node @types/express @types/pg @types/jsonwebtoken            @types/cookie-parser ts-node-dev vitest supertest
4
5# tsconfig.json
6{
7  "compilerOptions": {
8    "target": "ES2022", "module": "commonjs",
9    "outDir": "dist", "rootDir": "src",
10    "strict": true, "exactOptionalPropertyTypes": true,
11    "noUncheckedIndexedAccess": true, "esModuleInterop": true
12  }
13}
14
15src/
16├── app.ts              # Express app factory
17├── server.ts           # Entry point — bind port
18├── config.ts           # Env var validation (Zod)
19├── middleware/
20│   ├── auth.ts
21│   ├── error-handler.ts
22│   └── validate.ts
23├── routes/
24│   └── users/
25│       ├── users.router.ts
26│       ├── users.controller.ts
27│       ├── users.service.ts
28│       └── users.schema.ts
29└── db/
30    ├── pool.ts
31    └── users.repository.ts

Input Validation with Zod

TypeScript
1// routes/users/users.schema.ts
2import { z } from 'zod';
3
4export const createUserSchema = z.object({
5  name:     z.string().min(2).max(100).trim(),
6  email:    z.string().email().toLowerCase(),
7  password: z.string().min(8).regex(
8    /^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])/,
9    'Password must contain uppercase, lowercase, and number'
10  ),
11});
12
13export type CreateUserInput = z.infer<typeof createUserSchema>;
14
15// middleware/validate.ts
16import { Request, Response, NextFunction } from 'express';
17import { ZodSchema, ZodError } from 'zod';
18
19export function validate(schema: ZodSchema) {
20  return (req: Request, res: Response, next: NextFunction) => {
21    const result = schema.safeParse(req.body);
22    if (!result.success) {
23      return res.status(400).json({
24        error: 'Validation failed',
25        issues: result.error.flatten().fieldErrors,
26      });
27    }
28    req.body = result.data;
29    next();
30  };
31}

Database Layer

TypeScript
1// db/pool.ts
2import { Pool } from 'pg';
3
4export const pool = new Pool({
5  connectionString: process.env['DATABASE_URL'],
6  max: 20,
7  idleTimeoutMillis: 30_000,
8  connectionTimeoutMillis: 2_000,
9});
10
11// db/users.repository.ts
12import { pool } from './pool';
13
14interface User { id: string; name: string; email: string; created_at: Date }
15
16export async function findUserByEmail(email: string): Promise<User | null> {
17  const result = await pool.query<User>(
18    'SELECT id, name, email, created_at FROM users WHERE email = $1',
19    [email]
20  );
21  return result.rows[0] ?? null;
22}
23
24export async function createUser(
25  name: string, email: string, passwordHash: string
26): Promise<User> {
27  const result = await pool.query<User>(
28    'INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id, name, email, created_at',
29    [name, email, passwordHash]
30  );
31  const row = result.rows[0];
32  if (!row) throw new Error('Insert returned no row');
33  return row;
34}

Authentication

TypeScript
1import jwt from 'jsonwebtoken';
2import { Request, Response, NextFunction } from 'express';
3
4const JWT_SECRET = process.env['JWT_SECRET'];
5if (!JWT_SECRET) throw new Error('JWT_SECRET is required');
6
7interface TokenPayload { sub: string; email: string }
8
9export function signToken(payload: TokenPayload): string {
10  return jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
11}
12
13export function requireAuth(req: Request, res: Response, next: NextFunction) {
14  const token = req.cookies['access_token'] as string | undefined;
15  if (!token) return res.status(401).json({ error: 'Unauthorised' });
16
17  try {
18    const payload = jwt.verify(token, JWT_SECRET) as TokenPayload;
19    req.user = payload;
20    next();
21  } catch {
22    return res.status(401).json({ error: 'Invalid or expired token' });
23  }
24}

Error Handling

TypeScript
1// middleware/error-handler.ts
2import { Request, Response, NextFunction } from 'express';
3import { logger } from '../logger';
4
5export class AppError extends Error {
6  constructor(public statusCode: number, message: string) {
7    super(message);
8    this.name = 'AppError';
9  }
10}
11
12export function errorHandler(
13  err: unknown, req: Request, res: Response, _next: NextFunction
14) {
15  if (err instanceof AppError) {
16    return res.status(err.statusCode).json({ error: err.message });
17  }
18
19  logger.error({ err, path: req.path }, 'Unhandled error');
20  // Never expose internal details to clients
21  return res.status(500).json({ error: 'Internal server error' });
22}

Testing

TypeScript
1// routes/users/users.test.ts
2import { describe, it, expect, vi, beforeEach } from 'vitest';
3import request from 'supertest';
4import { app } from '../../app';
5import * as repo from '../../db/users.repository';
6
7vi.mock('../../db/users.repository');
8
9describe('POST /api/users', () => {
10  beforeEach(() => vi.clearAllMocks());
11
12  it('creates a user and returns 201', async () => {
13    vi.mocked(repo.findUserByEmail).mockResolvedValue(null);
14    vi.mocked(repo.createUser).mockResolvedValue({
15      id: '1', name: 'Alice', email: '[email protected]', created_at: new Date(),
16    });
17
18    const res = await request(app).post('/api/users').send({
19      name: 'Alice', email: '[email protected]', password: 'SecurePass1',
20    });
21
22    expect(res.status).toBe(201);
23    expect(res.body).toHaveProperty('id');
24    expect(res.body).not.toHaveProperty('password_hash');
25  });
26
27  it('returns 400 on invalid email', async () => {
28    const res = await request(app).post('/api/users').send({ name: 'Bob', email: 'not-an-email', password: 'pass' });
29    expect(res.status).toBe(400);
30  });
31});

Logging & Observability

TypeScript
1import pino from 'pino';
2import pinoHttp from 'pino-http';
3
4export const logger = pino({
5  level: process.env['LOG_LEVEL'] ?? 'info',
6  ...(process.env['NODE_ENV'] !== 'production' && {
7    transport: { target: 'pino-pretty', options: { colorize: true } },
8  }),
9});
10
11// app.ts
12app.use(pinoHttp({
13  logger,
14  customLogLevel: (req, res) => res.statusCode >= 500 ? 'error' : 'info',
15  redact: ['req.headers.authorization', 'req.headers.cookie'],
16}));