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.tsInput 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}));