Deep Dive: Node.js API Development with TypeScript

Welcome to the Deep Dive
You learned the basics of Node.js backend development in Phase 3. Now it's time to go deeper. This post covers production patterns that separate hobby projects from real-world APIs — type-safe middleware chains, runtime validation, centralized error handling, secure authentication, file uploads, background jobs, and structured logging.
These are patterns used at companies like Stripe, Vercel, and Linear. Master them, and you'll build APIs that are maintainable, secure, and a pleasure to work with.
Prerequisite: Phase 3: Backend Development Time commitment: 2-3 hours (work through the examples)
What You'll Learn
✅ Express.js with strict TypeScript typing (request, response, middleware)
✅ Fastify with built-in type inference and schema validation
✅ Request validation with Zod (body, params, query)
✅ Middleware patterns (auth, logging, rate limiting, error handling)
✅ Centralized error handling with custom error classes
✅ JWT authentication middleware with typed user context
✅ File uploads with Multer and type safety
✅ Background jobs with BullMQ
✅ Structured logging with Pino
✅ Building a production-ready API template
1. Type-Safe Express.js Setup
Extending Express Types
Express's default types are loose. Let's fix that by extending the Request object to include authenticated user data:
// src/types/express.d.ts
import { JwtPayload } from "../auth/jwt";
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
requestId: string;
}
}
}
export {};Typed Request Handlers
Create a helper type that enforces request body, params, and query shapes:
// src/types/handler.ts
import { Request, Response, NextFunction } from "express";
interface TypedRequest<
TBody = unknown,
TParams = Record<string, string>,
TQuery = Record<string, string>
> extends Request {
body: TBody;
params: TParams;
query: TQuery;
}
type AsyncHandler<
TBody = unknown,
TParams = Record<string, string>,
TQuery = Record<string, string>
> = (
req: TypedRequest<TBody, TParams, TQuery>,
res: Response,
next: NextFunction
) => Promise<void>;Using Typed Handlers
// src/routes/users.ts
import { Router } from "express";
import { AsyncHandler } from "../types/handler";
interface CreateUserBody {
name: string;
email: string;
password: string;
}
interface UserParams {
id: string;
}
const createUser: AsyncHandler<CreateUserBody> = async (req, res) => {
const { name, email, password } = req.body; // Fully typed
// ...create user logic
res.status(201).json({ id: "1", name, email });
};
const getUser: AsyncHandler<unknown, UserParams> = async (req, res) => {
const { id } = req.params; // string, typed
// ...fetch user logic
res.json({ id, name: "John" });
};
const router = Router();
router.post("/users", createUser);
router.get("/users/:id", getUser);
export default router;2. Fastify: Built-in Type Safety
Fastify has first-class TypeScript support. Unlike Express, types flow through the entire request lifecycle.
Basic Fastify Setup
// src/server.ts
import Fastify from "fastify";
const app = Fastify({
logger: true,
});
// Route with inline schema typing
app.get<{
Params: { id: string };
Querystring: { fields?: string };
Reply: { id: string; name: string; email: string };
}>("/users/:id", async (request, reply) => {
const { id } = request.params; // typed as string
const { fields } = request.query; // typed as string | undefined
return { id, name: "John", email: "john@example.com" };
});
app.listen({ port: 3000 });Fastify with JSON Schema Validation
Fastify validates requests and serializes responses using JSON Schema — at runtime:
import Fastify, { FastifyInstance } from "fastify";
const app: FastifyInstance = Fastify({ logger: true });
// Define schemas for compile-time AND runtime safety
const createUserSchema = {
body: {
type: "object",
required: ["name", "email", "password"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 },
},
},
response: {
201: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
email: { type: "string" },
},
},
},
} as const;
interface CreateUserBody {
name: string;
email: string;
password: string;
}
app.post<{ Body: CreateUserBody }>(
"/users",
{ schema: createUserSchema },
async (request, reply) => {
const { name, email, password } = request.body; // validated + typed
// ...create user
reply.status(201).send({ id: "1", name, email });
}
);Express vs Fastify: When to Choose
| Feature | Express | Fastify |
|---|---|---|
| TypeScript | Requires @types/express + manual typing | Built-in type inference |
| Validation | External library (Zod, Joi) | Built-in JSON Schema |
| Performance | ~15,000 req/s | ~45,000 req/s |
| Middleware | .use() middleware chain | Plugin system with encapsulation |
| Ecosystem | Largest (npm) | Growing, compatible with Express middleware |
| Learning curve | Gentle | Moderate |
| Best for | Quick MVPs, large existing codebases | Performance-critical APIs, new projects |
Recommendation: Use Express if you're working with an existing Express codebase or need the largest ecosystem. Use Fastify for new projects where performance and type safety matter.
3. Request Validation with Zod
Runtime validation is critical — TypeScript types disappear after compilation. Zod bridges the gap between compile-time types and runtime safety.
Setting Up Zod Schemas
// src/schemas/user.schema.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z
.string()
.min(8)
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Must contain uppercase, lowercase, and number"
),
});
export const updateUserSchema = createUserSchema.partial().extend({
bio: z.string().max(500).optional(),
});
export const userParamsSchema = z.object({
id: z.string().uuid(),
});
export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["name", "email", "createdAt"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
search: z.string().optional(),
});
// Infer TypeScript types from schemas
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type UserParams = z.infer<typeof userParamsSchema>;
export type ListUsersQuery = z.infer<typeof listUsersQuerySchema>;Validation Middleware
Create a reusable middleware that validates any part of the request:
// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";
interface ValidationSchemas {
body?: AnyZodObject;
params?: AnyZodObject;
query?: AnyZodObject;
}
export function validate(schemas: ValidationSchemas) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
if (schemas.body) {
req.body = await schemas.body.parseAsync(req.body);
}
if (schemas.params) {
req.params = await schemas.params.parseAsync(req.params);
}
if (schemas.query) {
req.query = await schemas.query.parseAsync(req.query);
}
next();
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
error: "Validation failed",
details: error.errors.map((e) => ({
field: e.path.join("."),
message: e.message,
})),
});
return;
}
next(error);
}
};
}Using Validation in Routes
// src/routes/users.ts
import { Router } from "express";
import { validate } from "../middleware/validate";
import {
createUserSchema,
updateUserSchema,
userParamsSchema,
listUsersQuerySchema,
} from "../schemas/user.schema";
import type { CreateUserInput, UserParams, ListUsersQuery } from "../schemas/user.schema";
const router = Router();
// POST /users - validated body
router.post(
"/users",
validate({ body: createUserSchema }),
async (req, res) => {
const data = req.body as CreateUserInput; // safe after validation
// ...create user
res.status(201).json({ id: "1", ...data });
}
);
// GET /users - validated query params
router.get(
"/users",
validate({ query: listUsersQuerySchema }),
async (req, res) => {
const { page, limit, sort, order, search } = req.query as unknown as ListUsersQuery;
// All typed and validated: page is number, sort is "name" | "email" | "createdAt"
res.json({ page, limit, data: [] });
}
);
// PUT /users/:id - validated params + body
router.put(
"/users/:id",
validate({ params: userParamsSchema, body: updateUserSchema }),
async (req, res) => {
const { id } = req.params as unknown as UserParams;
// id is guaranteed to be a valid UUID
res.json({ id, ...req.body });
}
);
export default router;4. Middleware Patterns
Middleware Type Definitions
// src/types/middleware.ts
import { Request, Response, NextFunction } from "express";
// Standard middleware
type Middleware = (req: Request, res: Response, next: NextFunction) => void;
// Async middleware (catches promise rejections)
type AsyncMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void>;
// Error middleware (4 parameters)
type ErrorMiddleware = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => void;Async Wrapper
Express doesn't catch rejected promises from async handlers. Fix this with a wrapper:
// src/middleware/asyncHandler.ts
import { Request, Response, NextFunction } from "express";
type AsyncRequestHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void>;
export function asyncHandler(fn: AsyncRequestHandler) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
// Usage
router.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError("User not found");
}
res.json(user);
})
);Request ID Middleware
Trace every request through your system:
// src/middleware/requestId.ts
import { randomUUID } from "crypto";
import { Request, Response, NextFunction } from "express";
export function requestId(req: Request, res: Response, next: NextFunction) {
const id = (req.headers["x-request-id"] as string) || randomUUID();
req.requestId = id;
res.setHeader("x-request-id", id);
next();
}Rate Limiting Middleware
// src/middleware/rateLimit.ts
import { Request, Response, NextFunction } from "express";
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
}
interface RateLimitEntry {
count: number;
resetTime: number;
}
export function rateLimit({ windowMs, maxRequests }: RateLimitConfig) {
const store = new Map<string, RateLimitEntry>();
// Clean expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetTime < now) {
store.delete(key);
}
}
}, windowMs);
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || "unknown";
const now = Date.now();
const entry = store.get(key);
if (!entry || entry.resetTime < now) {
store.set(key, { count: 1, resetTime: now + windowMs });
next();
return;
}
if (entry.count >= maxRequests) {
res.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
});
return;
}
entry.count++;
next();
};
}
// Usage
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, maxRequests: 10 }));
app.use("/api", rateLimit({ windowMs: 60 * 1000, maxRequests: 100 }));Composing Middleware
Chain middleware in a readable, type-safe way:
// src/middleware/compose.ts
import { RequestHandler } from "express";
export function compose(...middlewares: RequestHandler[]): RequestHandler[] {
return middlewares;
}
// Usage: clean route definitions
router.post(
"/posts",
...compose(
authenticate,
authorize("admin", "editor"),
validate({ body: createPostSchema }),
),
asyncHandler(postController.create)
);5. Centralized Error Handling
Custom Error Classes
// src/errors/AppError.ts
export class AppError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string,
public details?: Record<string, unknown>
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, "NOT_FOUND");
}
}
export class ValidationError extends AppError {
constructor(details: Record<string, unknown>) {
super("Validation failed", 400, "VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Authentication required") {
super(message, 401, "UNAUTHORIZED");
}
}
export class ForbiddenError extends AppError {
constructor(message = "Insufficient permissions") {
super(message, 403, "FORBIDDEN");
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, "CONFLICT");
}
}Global Error Handler
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../errors/AppError";
import { ZodError } from "zod";
import { logger } from "../lib/logger";
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
// Log the error
logger.error({
err,
requestId: req.requestId,
method: req.method,
url: req.url,
});
// Known application errors
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err.details && { details: err.details }),
},
});
return;
}
// Zod validation errors
if (err instanceof ZodError) {
res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid request data",
details: err.errors.map((e) => ({
field: e.path.join("."),
message: e.message,
})),
},
});
return;
}
// Unknown errors — don't leak internal details
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
}Using Errors in Services
// src/services/user.service.ts
import { NotFoundError, ConflictError } from "../errors/AppError";
import { prisma } from "../lib/prisma";
import type { CreateUserInput } from "../schemas/user.schema";
export class UserService {
async create(data: CreateUserInput) {
const existing = await prisma.user.findUnique({
where: { email: data.email },
});
if (existing) {
throw new ConflictError("Email already registered");
}
return prisma.user.create({ data });
}
async findById(id: string) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundError("User");
}
return user;
}
}6. JWT Authentication Middleware
JWT Utility Functions
// src/auth/jwt.ts
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = "24h";
const REFRESH_TOKEN_EXPIRES_IN = "7d";
export interface JwtPayload {
userId: string;
email: string;
role: "admin" | "user" | "editor";
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export function generateTokens(payload: JwtPayload): TokenPair {
const accessToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
const refreshToken = jwt.sign(
{ userId: payload.userId, type: "refresh" },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN }
);
return { accessToken, refreshToken };
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
}Authentication Middleware
// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../auth/jwt";
import { UnauthorizedError } from "../errors/AppError";
export function authenticate(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedError("Missing or invalid authorization header");
}
const token = header.slice(7);
try {
const payload = verifyToken(token);
req.user = payload; // Available in all subsequent handlers
next();
} catch {
throw new UnauthorizedError("Invalid or expired token");
}
}Authorization Middleware
// src/middleware/authorize.ts
import { Request, Response, NextFunction } from "express";
import { ForbiddenError, UnauthorizedError } from "../errors/AppError";
type Role = "admin" | "user" | "editor";
export function authorize(...allowedRoles: Role[]) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user) {
throw new UnauthorizedError();
}
if (!allowedRoles.includes(req.user.role)) {
throw new ForbiddenError(
`Role '${req.user.role}' cannot access this resource`
);
}
next();
};
}
// Usage
router.delete(
"/users/:id",
authenticate,
authorize("admin"),
asyncHandler(async (req, res) => {
await userService.delete(req.params.id);
res.status(204).send();
})
);Protected Route Pattern
// src/routes/protected.ts
import { Router } from "express";
import { authenticate } from "../middleware/authenticate";
import { authorize } from "../middleware/authorize";
import { asyncHandler } from "../middleware/asyncHandler";
const router = Router();
// All routes in this router require authentication
router.use(authenticate);
// GET /me — any authenticated user
router.get(
"/me",
asyncHandler(async (req, res) => {
const user = await userService.findById(req.user!.userId);
res.json(user);
})
);
// Admin-only routes
router.get(
"/admin/stats",
authorize("admin"),
asyncHandler(async (req, res) => {
const stats = await adminService.getStats();
res.json(stats);
})
);
export default router;7. File Uploads with Multer
Multer Configuration
// src/middleware/upload.ts
import multer, { FileFilterCallback } from "multer";
import { Request } from "express";
import path from "path";
import { randomUUID } from "crypto";
// Define allowed file types
const ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
] as const;
type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];
function isAllowedMimeType(mime: string): mime is AllowedMimeType {
return ALLOWED_MIME_TYPES.includes(mime as AllowedMimeType);
}
// File filter
const fileFilter = (
_req: Request,
file: Express.Multer.File,
cb: FileFilterCallback
) => {
if (isAllowedMimeType(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} not allowed`));
}
};
// Storage configuration
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, "uploads/");
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${randomUUID()}${ext}`);
},
});
// Export configured uploaders
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 5, // Max 5 files per request
},
});Upload Routes
// src/routes/upload.ts
import { Router } from "express";
import { upload } from "../middleware/upload";
import { authenticate } from "../middleware/authenticate";
import { asyncHandler } from "../middleware/asyncHandler";
const router = Router();
// Single file upload
router.post(
"/avatar",
authenticate,
upload.single("avatar"),
asyncHandler(async (req, res) => {
if (!req.file) {
res.status(400).json({ error: "No file uploaded" });
return;
}
const { filename, mimetype, size } = req.file;
// Save file reference to database
const url = `/uploads/${filename}`;
res.json({ url, mimetype, size });
})
);
// Multiple file upload
router.post(
"/gallery",
authenticate,
upload.array("images", 5),
asyncHandler(async (req, res) => {
const files = req.files as Express.Multer.File[];
const uploaded = files.map((file) => ({
url: `/uploads/${file.filename}`,
mimetype: file.mimetype,
size: file.size,
}));
res.json({ files: uploaded });
})
);
export default router;8. Background Jobs with BullMQ
Queue Setup
// src/jobs/queue.ts
import { Queue, Worker, Job } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
// Define job types with a union
interface EmailJobData {
type: "email";
to: string;
subject: string;
body: string;
}
interface ImageResizeJobData {
type: "image-resize";
path: string;
width: number;
height: number;
}
interface ReportJobData {
type: "report";
userId: string;
startDate: string;
endDate: string;
}
type JobData = EmailJobData | ImageResizeJobData | ReportJobData;
// Create typed queue
export const taskQueue = new Queue<JobData>("tasks", { connection });
// Type-safe job dispatchers
export async function sendEmail(data: Omit<EmailJobData, "type">) {
return taskQueue.add("email", { type: "email", ...data }, {
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
});
}
export async function resizeImage(data: Omit<ImageResizeJobData, "type">) {
return taskQueue.add("image-resize", { type: "image-resize", ...data });
}
export async function generateReport(data: Omit<ReportJobData, "type">) {
return taskQueue.add("report", { type: "report", ...data }, {
attempts: 2,
backoff: { type: "fixed", delay: 5000 },
});
}Worker with Type-Safe Processing
// src/jobs/worker.ts
import { Worker, Job } from "bullmq";
import IORedis from "ioredis";
import { logger } from "../lib/logger";
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
async function processEmail(data: { to: string; subject: string; body: string }) {
// Send email logic (e.g., with Resend, SendGrid)
logger.info({ to: data.to, subject: data.subject }, "Email sent");
}
async function processImageResize(data: { path: string; width: number; height: number }) {
// Image resize logic (e.g., with Sharp)
logger.info({ path: data.path, width: data.width }, "Image resized");
}
async function processReport(data: { userId: string; startDate: string; endDate: string }) {
// Generate report logic
logger.info({ userId: data.userId }, "Report generated");
}
const worker = new Worker(
"tasks",
async (job: Job) => {
const { type, ...data } = job.data;
switch (type) {
case "email":
await processEmail(data as { to: string; subject: string; body: string });
break;
case "image-resize":
await processImageResize(data as { path: string; width: number; height: number });
break;
case "report":
await processReport(data as { userId: string; startDate: string; endDate: string });
break;
default:
throw new Error(`Unknown job type: ${type}`);
}
},
{ connection, concurrency: 5 }
);
worker.on("completed", (job) => {
logger.info({ jobId: job.id, type: job.data.type }, "Job completed");
});
worker.on("failed", (job, err) => {
logger.error({ jobId: job?.id, error: err.message }, "Job failed");
});
export default worker;Using Jobs in Routes
// src/routes/admin.ts
import { Router } from "express";
import { sendEmail, generateReport } from "../jobs/queue";
import { authenticate } from "../middleware/authenticate";
import { authorize } from "../middleware/authorize";
import { asyncHandler } from "../middleware/asyncHandler";
const router = Router();
router.post(
"/invite",
authenticate,
authorize("admin"),
asyncHandler(async (req, res) => {
const { email, name } = req.body;
// Queue email — don't block the response
await sendEmail({
to: email,
subject: "You're invited!",
body: `Hello ${name}, you've been invited to join our platform.`,
});
res.json({ message: "Invitation sent" });
})
);
router.post(
"/reports",
authenticate,
authorize("admin"),
asyncHandler(async (req, res) => {
const { startDate, endDate } = req.body;
const job = await generateReport({
userId: req.user!.userId,
startDate,
endDate,
});
res.json({ message: "Report queued", jobId: job.id });
})
);
export default router;9. Structured Logging with Pino
Logger Configuration
// src/lib/logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
}
: undefined,
// Production: JSON output for log aggregation (ELK, Datadog)
serializers: {
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
err: pino.stdSerializers.err,
},
});
// Create child loggers for different modules
export const dbLogger = logger.child({ module: "database" });
export const authLogger = logger.child({ module: "auth" });
export const jobLogger = logger.child({ module: "jobs" });Request Logging Middleware
// src/middleware/requestLogger.ts
import { Request, Response, NextFunction } from "express";
import { logger } from "../lib/logger";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({
requestId: req.requestId,
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
duration: `${duration}ms`,
userAgent: req.headers["user-agent"],
ip: req.ip,
...(req.user && { userId: req.user.userId }),
});
});
next();
}Structured Logging Best Practices
// DON'T: String concatenation (hard to parse, search, filter)
logger.info("User " + userId + " created post " + postId);
// DO: Structured data (searchable, filterable in log aggregation)
logger.info({ userId, postId, action: "create_post" }, "Post created");
// DON'T: Log sensitive data
logger.info({ email, password }, "Login attempt");
// DO: Redact sensitive fields
logger.info({ email, hasPassword: !!password }, "Login attempt");
// DO: Use child loggers for context
const reqLogger = logger.child({ requestId: req.requestId, userId: req.user?.userId });
reqLogger.info("Processing order");
reqLogger.info({ orderId: "123" }, "Order processed");
// All logs automatically include requestId and userId10. Putting It All Together: API Template
Application Setup
// src/app.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { requestId } from "./middleware/requestId";
import { requestLogger } from "./middleware/requestLogger";
import { errorHandler } from "./middleware/errorHandler";
import { rateLimit } from "./middleware/rateLimit";
import userRoutes from "./routes/users";
import authRoutes from "./routes/auth";
import uploadRoutes from "./routes/upload";
import adminRoutes from "./routes/admin";
const app = express();
// --- Global Middleware ---
app.use(helmet()); // Security headers
app.use(cors({ origin: process.env.CORS_ORIGIN || "*" }));
app.use(express.json({ limit: "10mb" }));
app.use(requestId);
app.use(requestLogger);
// --- Rate Limiting ---
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, maxRequests: 20 }));
app.use("/api", rateLimit({ windowMs: 60 * 1000, maxRequests: 100 }));
// --- Routes ---
app.use("/api/auth", authRoutes);
app.use("/api/users", userRoutes);
app.use("/api/upload", uploadRoutes);
app.use("/api/admin", adminRoutes);
// --- Health Check ---
app.get("/health", (_req, res) => {
res.json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
// --- Error Handler (must be last) ---
app.use(errorHandler);
export default app;Server Entry Point
// src/index.ts
import app from "./app";
import { logger } from "./lib/logger";
const PORT = parseInt(process.env.PORT || "3000", 10);
async function main() {
// Start the server
app.listen(PORT, () => {
logger.info({ port: PORT, env: process.env.NODE_ENV }, "Server started");
});
// Graceful shutdown
const shutdown = async (signal: string) => {
logger.info({ signal }, "Shutting down gracefully");
// Close server, database connections, job queues, etc.
process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
main().catch((err) => {
logger.fatal({ err }, "Failed to start server");
process.exit(1);
});Project Structure
src/
├── index.ts # Entry point
├── app.ts # Express app setup
├── auth/
│ └── jwt.ts # JWT utilities
├── errors/
│ └── AppError.ts # Custom error classes
├── jobs/
│ ├── queue.ts # Job queue + dispatchers
│ └── worker.ts # Job processor
├── lib/
│ ├── logger.ts # Pino logger
│ └── prisma.ts # Prisma client
├── middleware/
│ ├── asyncHandler.ts # Async error wrapper
│ ├── authenticate.ts # JWT auth
│ ├── authorize.ts # Role-based access
│ ├── errorHandler.ts # Global error handler
│ ├── rateLimit.ts # Rate limiting
│ ├── requestId.ts # Request tracing
│ ├── requestLogger.ts # HTTP logging
│ ├── upload.ts # File upload (Multer)
│ └── validate.ts # Zod validation
├── routes/
│ ├── admin.ts # Admin routes
│ ├── auth.ts # Auth routes
│ ├── upload.ts # Upload routes
│ └── users.ts # User routes
├── schemas/
│ └── user.schema.ts # Zod schemas + types
├── services/
│ └── user.service.ts # Business logic
└── types/
├── express.d.ts # Express augmentation
├── handler.ts # Typed request helpers
└── middleware.ts # Middleware typesCommon Pitfalls
1. Not Validating at Runtime
// BAD: TypeScript types don't exist at runtime
interface CreateUserBody {
email: string;
}
router.post("/users", (req, res) => {
const { email } = req.body as CreateUserBody;
// email could be anything — number, null, undefined
});
// GOOD: Validate with Zod, then type is guaranteed
const schema = z.object({ email: z.string().email() });
router.post("/users", validate({ body: schema }), (req, res) => {
const { email } = req.body; // Actually validated
});2. Forgetting to Catch Async Errors
// BAD: Unhandled promise rejection crashes the server
router.get("/users/:id", async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
res.json(user); // What if db throws?
});
// GOOD: Use asyncHandler wrapper
router.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
res.json(user); // Errors forwarded to error handler
})
);3. Leaking Error Details in Production
// BAD: Exposes stack traces and internal errors
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// GOOD: Use AppError for known errors, generic message for unknown
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: { code: err.code, message: err.message } });
} else {
res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } });
}
});4. Using any to Silence Type Errors
// BAD: Defeats the purpose of TypeScript
const handler = (req: any, res: any) => {
res.json(req.body.whatever);
};
// GOOD: Properly type or use unknown + validation
const handler = (req: Request, res: Response) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ errors: result.error.flatten() });
return;
}
res.json(result.data); // Typed!
};Summary and Key Takeaways
✅ Express typing: Extend Request interface, create TypedRequest helpers for body/params/query
✅ Fastify: Built-in type inference — less boilerplate, better DX for new projects
✅ Zod validation: Bridge compile-time types and runtime safety with z.infer<typeof schema>
✅ Middleware: Compose authentication, authorization, validation, and rate limiting cleanly
✅ Error handling: Use custom error classes + centralized error handler — never leak internals
✅ Authentication: JWT with typed payload, authenticate + authorize middleware chain
✅ File uploads: Multer with typed configuration and file filtering
✅ Background jobs: BullMQ with discriminated union job types and type-safe dispatchers
✅ Logging: Pino structured logging — child loggers, redaction, JSON for production
What's Next?
- Deep Dive: Database & ORMs → — Prisma and Drizzle ORM patterns with TypeScript
- Deep Dive: Testing & DevOps → — Vitest, Playwright, CI/CD, Docker
Additional Resources
- Express TypeScript Guide
- Fastify TypeScript Documentation
- Zod Documentation
- BullMQ Documentation
- Pino Logger
Series: TypeScript Full-Stack Roadmap Previous: Deep Dive: React with TypeScript Next: Deep Dive: Database & ORMs
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.