Node.js Backend Development with TypeScript
progressive_disclosure: entry_point: summary: "TypeScript backend patterns with Express/Fastify, routing, middleware, database integration" when_to_use:
- "When building REST APIs with TypeScript"
- "When creating Express/Fastify servers"
- "When needing server-side TypeScript"
- "When building microservices" quick_start:
- "npm init -y && npm install -D typescript @types/node tsx"
- "npm install express @types/express zod"
- "Create tsconfig.json with strict mode"
- "npm run dev" token_estimate: entry: 75 full: 4700
TypeScript Setup
Essential Configuration
tsconfig.json (strict mode recommended):
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
package.json scripts:
{ "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js", "test": "vitest" } }
Development Dependencies
npm install -D typescript @types/node tsx vitest npm install -D @types/express # or @types/node (Fastify has built-in types)
Express Patterns
Basic Express Server
src/server.ts:
import express, { Request, Response, NextFunction } from 'express'; import { z } from 'zod';
const app = express(); const port = process.env.PORT || 3000;
// Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true }));
// Type-safe request handlers interface TypedRequest<T> extends Request { body: T; }
// Routes app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); });
// Start server
app.listen(port, () => {
console.log(Server running on port ${port});
});
Router Pattern
src/routes/users.ts:
import { Router } from 'express'; import { z } from 'zod'; import { validateRequest } from '../middleware/validation';
const router = Router();
const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(2), age: z.number().int().positive().optional(), });
router.post( '/users', validateRequest(createUserSchema), async (req, res, next) => { try { const userData = req.body; // Type-safe after validation // Database insert logic res.status(201).json({ id: 1, ...userData }); } catch (error) { next(error); } } );
export default router;
Middleware Patterns
src/middleware/validation.ts:
import { Request, Response, NextFunction } from 'express'; import { z, ZodSchema } from 'zod';
export const validateRequest = (schema: ZodSchema) => { return (req: Request, res: Response, next: NextFunction) => { try { req.body = schema.parse(req.body); next(); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Validation failed', details: error.errors, }); } else { next(error); } } }; };
src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken';
interface JwtPayload { userId: string; email: string; }
declare global { namespace Express { interface Request { user?: JwtPayload; } } }
export const authenticate = ( req: Request, res: Response, next: NextFunction ) => { const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) { return res.status(401).json({ error: 'No token provided' }); }
try { const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; req.user = decoded; next(); } catch (error) { res.status(401).json({ error: 'Invalid token' }); } };
Error Handling
src/middleware/errorHandler.ts:
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error { constructor( public statusCode: number, message: string, public isOperational = true ) { super(message); Object.setPrototypeOf(this, AppError.prototype); } }
export const errorHandler = ( err: Error, req: Request, res: Response, next: NextFunction ) => { if (err instanceof AppError) { return res.status(err.statusCode).json({ error: err.message, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), }); }
console.error('Unexpected error:', err); res.status(500).json({ error: 'Internal server error', ...(process.env.NODE_ENV === 'development' && { message: err.message, stack: err.stack, }), }); };
Fastify Patterns
Basic Fastify Server
src/server.ts:
import Fastify from 'fastify'; import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Type } from '@sinclair/typebox';
const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info', }, }).withTypeProvider<TypeBoxTypeProvider>();
// Type-safe route with schema validation fastify.route({ method: 'POST', url: '/users', schema: { body: Type.Object({ email: Type.String({ format: 'email' }), name: Type.String({ minLength: 2 }), age: Type.Optional(Type.Integer({ minimum: 0 })), }), response: { 201: Type.Object({ id: Type.Number(), email: Type.String(), name: Type.String(), }), }, }, handler: async (request, reply) => { const { email, name, age } = request.body; // Auto-typed and validated return reply.status(201).send({ id: 1, email, name }); }, });
const start = async () => { try { await fastify.listen({ port: 3000, host: '0.0.0.0' }); } catch (err) { fastify.log.error(err); process.exit(1); } };
start();
Plugin Pattern
src/plugins/database.ts:
import { FastifyPluginAsync } from 'fastify'; import fp from 'fastify-plugin'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg';
declare module 'fastify' { interface FastifyInstance { db: ReturnType<typeof drizzle>; } }
const databasePlugin: FastifyPluginAsync = async (fastify) => { const pool = new Pool({ connectionString: process.env.DATABASE_URL, });
const db = drizzle(pool); fastify.decorate('db', db);
fastify.addHook('onClose', async () => { await pool.end(); }); };
export default fp(databasePlugin);
Hooks Pattern
src/hooks/auth.ts:
import { FastifyRequest, FastifyReply } from 'fastify'; import jwt from 'jsonwebtoken';
declare module 'fastify' { interface FastifyRequest { user?: { userId: string; email: string; }; } }
export const authHook = async ( request: FastifyRequest, reply: FastifyReply ) => { const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) { return reply.status(401).send({ error: 'No token provided' }); }
try { const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; email: string; }; request.user = decoded; } catch (error) { return reply.status(401).send({ error: 'Invalid token' }); } };
Request Validation
Zod with Express
import { z } from 'zod';
const userSchema = z.object({ email: z.string().email(), password: z.string().min(8), profile: z.object({ firstName: z.string(), lastName: z.string(), age: z.number().int().positive(), }), tags: z.array(z.string()).optional(), });
type CreateUserInput = z.infer<typeof userSchema>;
router.post('/users', async (req, res) => { const result = userSchema.safeParse(req.body);
if (!result.success) { return res.status(400).json({ error: 'Validation failed', details: result.error.format(), }); }
const user: CreateUserInput = result.data; // Type-safe user object });
TypeBox with Fastify
import { Type, Static } from '@sinclair/typebox';
const UserSchema = Type.Object({ email: Type.String({ format: 'email' }), password: Type.String({ minLength: 8 }), profile: Type.Object({ firstName: Type.String(), lastName: Type.String(), age: Type.Integer({ minimum: 0 }), }), tags: Type.Optional(Type.Array(Type.String())), });
type User = Static<typeof UserSchema>;
fastify.post('/users', { schema: { body: UserSchema }, handler: async (request, reply) => { const user: User = request.body; // Auto-validated return { id: 1, ...user }; }, });
Authentication
JWT Authentication
src/services/auth.ts:
import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt';
interface TokenPayload { userId: string; email: string; }
export class AuthService { private static JWT_SECRET = process.env.JWT_SECRET!; private static JWT_EXPIRES_IN = '7d';
static async hashPassword(password: string): Promise<string> { return bcrypt.hash(password, 10); }
static async comparePassword( password: string, hash: string ): Promise<boolean> { return bcrypt.compare(password, hash); }
static generateToken(payload: TokenPayload): string { return jwt.sign(payload, this.JWT_SECRET, { expiresIn: this.JWT_EXPIRES_IN, }); }
static verifyToken(token: string): TokenPayload { return jwt.verify(token, this.JWT_SECRET) as TokenPayload; } }
Session-based Auth (Express)
import session from 'express-session'; import RedisStore from 'connect-redis'; import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL, }); redisClient.connect();
app.use( session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days }, }) );
declare module 'express-session' { interface SessionData { userId: string; } }
Database Integration
Drizzle ORM
src/db/schema.ts:
import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', { id: serial('id').primaryKey(), email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), });
export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert;
src/db/client.ts:
import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import * as schema from './schema';
const pool = new Pool({ connectionString: process.env.DATABASE_URL, });
export const db = drizzle(pool, { schema });
src/repositories/userRepository.ts:
import { eq } from 'drizzle-orm'; import { db } from '../db/client'; import { users, NewUser } from '../db/schema';
export class UserRepository { static async create(data: NewUser) { const [user] = await db.insert(users).values(data).returning(); return user; }
static async findByEmail(email: string) { return db.query.users.findFirst({ where: eq(users.email, email), }); }
static async findById(id: number) { return db.query.users.findFirst({ where: eq(users.id, id), }); }
static async list(limit = 10, offset = 0) { return db.query.users.findMany({ limit, offset, columns: { passwordHash: false, // Exclude sensitive fields }, }); } }
Prisma
prisma/schema.prisma:
datasource db { provider = "postgresql" url = env("DATABASE_URL") }
generator client { provider = "prisma-client-js" }
model User { id Int @id @default(autoincrement()) email String @unique name String passwordHash String @map("password_hash") createdAt DateTime @default(now()) @map("created_at") posts Post[]
@@map("users") }
model Post { id Int @id @default(autoincrement()) title String content String? published Boolean @default(false) authorId Int @map("author_id") author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) @map("created_at")
@@map("posts") }
src/services/userService.ts:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class UserService { static async createUser(data: { email: string; name: string; password: string }) { const passwordHash = await AuthService.hashPassword(data.password);
return prisma.user.create({
data: {
email: data.email,
name: data.name,
passwordHash,
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
}
static async getUserWithPosts(userId: number) { return prisma.user.findUnique({ where: { id: userId }, include: { posts: { where: { published: true }, orderBy: { createdAt: 'desc' }, }, }, }); } }
API Design
REST API Patterns
Pagination:
import { z } from 'zod';
const paginationSchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), });
router.get('/users', async (req, res) => { const { page, limit } = paginationSchema.parse(req.query); const offset = (page - 1) * limit;
const [users, total] = await Promise.all([ UserRepository.list(limit, offset), UserRepository.count(), ]);
res.json({ data: users, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }); });
Filtering and Sorting:
const filterSchema = z.object({ status: z.enum(['active', 'inactive']).optional(), search: z.string().optional(), sortBy: z.enum(['createdAt', 'name', 'email']).default('createdAt'), sortOrder: z.enum(['asc', 'desc']).default('desc'), });
router.get('/users', async (req, res) => { const filters = filterSchema.parse(req.query);
const users = await db.query.users.findMany({
where: and(
filters.status && eq(users.status, filters.status),
filters.search && ilike(users.name, %${filters.search}%)
),
orderBy: [
filters.sortOrder === 'asc'
? asc(users[filters.sortBy])
: desc(users[filters.sortBy]),
],
});
res.json({ data: users }); });
Error Response Format
interface ErrorResponse { error: string; message: string; statusCode: number; details?: unknown; timestamp: string; path: string; }
export const formatError = ( err: AppError, req: Request ): ErrorResponse => ({ error: err.name, message: err.message, statusCode: err.statusCode, ...(err.details && { details: err.details }), timestamp: new Date().toISOString(), path: req.path, });
Environment Configuration
Type-safe Environment Variables
src/config/env.ts:
import { z } from 'zod';
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), REDIS_URL: z.string().url(), JWT_SECRET: z.string().min(32), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), });
export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);
Usage:
import { env } from './config/env';
const port = env.PORT; // Type-safe, validated
Testing
Vitest Setup
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, environment: 'node', setupFiles: ['./src/tests/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], }, }, });
Integration Tests with Supertest
src/tests/users.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { app } from '../server'; import { db } from '../db/client';
describe('User API', () => { beforeAll(async () => { // Setup test database await db.delete(users); });
afterAll(async () => { // Cleanup });
it('should create a new user', async () => { const response = await request(app) .post('/users') .send({ email: 'test@example.com', name: 'Test User', password: 'password123', }) .expect(201);
expect(response.body).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
expect(response.body).toHaveProperty('id');
expect(response.body).not.toHaveProperty('passwordHash');
});
it('should return 400 for invalid email', async () => { const response = await request(app) .post('/users') .send({ email: 'invalid-email', name: 'Test User', password: 'password123', }) .expect(400);
expect(response.body).toHaveProperty('error');
}); });
Unit Tests
src/services/auth.test.ts:
import { describe, it, expect } from 'vitest'; import { AuthService } from './auth';
describe('AuthService', () => { it('should hash password correctly', async () => { const password = 'mySecurePassword123'; const hash = await AuthService.hashPassword(password);
expect(hash).not.toBe(password);
expect(hash.length).toBeGreaterThan(50);
});
it('should verify password correctly', async () => { const password = 'mySecurePassword123'; const hash = await AuthService.hashPassword(password);
const isValid = await AuthService.comparePassword(password, hash);
expect(isValid).toBe(true);
const isInvalid = await AuthService.comparePassword('wrongPassword', hash);
expect(isInvalid).toBe(false);
});
it('should generate valid JWT token', () => { const token = AuthService.generateToken({ userId: '123', email: 'test@example.com', });
expect(token).toBeTruthy();
const decoded = AuthService.verifyToken(token);
expect(decoded).toMatchObject({
userId: '123',
email: 'test@example.com',
});
}); });
Production Deployment
Docker Setup
Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build
FROM node:20-alpine
WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production EXPOSE 3000
CMD ["node", "dist/server.js"]
docker-compose.yml:
version: '3.8'
services: app: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgresql://user:pass@db:5432/mydb - REDIS_URL=redis://redis:6379 - JWT_SECRET=${JWT_SECRET} depends_on: - db - redis
db: image: postgres:16-alpine environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=pass - POSTGRES_DB=mydb volumes: - postgres_data:/var/lib/postgresql/data
redis: image: redis:7-alpine volumes: - redis_data:/data
volumes: postgres_data: redis_data:
PM2 Clustering
ecosystem.config.js:
module.exports = { apps: [{ name: 'api', script: './dist/server.js', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', }], };
Best Practices
Project Structure
src/ ├── server.ts # Entry point ├── config/ │ └── env.ts # Environment config ├── routes/ │ ├── index.ts # Route aggregator │ ├── users.ts │ └── posts.ts ├── middleware/ │ ├── auth.ts │ ├── validation.ts │ └── errorHandler.ts ├── services/ │ ├── auth.ts │ └── user.ts ├── repositories/ │ └── userRepository.ts ├── db/ │ ├── client.ts │ └── schema.ts ├── types/ │ └── index.ts └── tests/ ├── setup.ts ├── users.test.ts └── auth.test.ts
Key Principles
-
Separation of Concerns: Routes → Controllers → Services → Repositories
-
Type Safety: Use TypeScript strict mode, Zod for runtime validation
-
Error Handling: Centralized error handler, custom error classes
-
Security: Helmet, rate limiting, input validation, CORS
-
Logging: Structured logging (pino, winston), request IDs
-
Testing: Unit tests for services, integration tests for APIs
-
Documentation: OpenAPI/Swagger for API documentation
Express vs Fastify
Use Express when:
-
Large ecosystem of middleware needed
-
Team familiarity is priority
-
Prototype/MVP development
-
Legacy codebase compatibility
Use Fastify when:
-
Performance is critical (2-3x faster)
-
Type safety is important (built-in TypeScript support)
-
Schema validation required (JSON Schema built-in)
-
Modern async/await patterns preferred
-
Plugin architecture needed
Performance Tips
-
Use connection pooling for databases
-
Implement caching (Redis, in-memory)
-
Enable compression (gzip, brotli)
-
Use clustering for CPU-intensive tasks
-
Implement rate limiting
-
Optimize database queries (indexes, query analysis)
-
Use CDN for static assets
-
Enable HTTP/2 in production