When building a personal finance tracker, data integrity and system reliability are non-negotiable. One missing try/catch block can introduce difficult-to-debug failures, and weak types can let invalid financial payloads corrupt your database.
While building the backend for my personal finance tracker, I decided to move past generic tutorials and build a scalable, production-grade API core using Express, TypeScript, and Zod.
In this post, I’ll show you how I implemented a type-safe middleware ecosystem, leveraged TypeScript declaration merging to extend the native Request object, and eliminated repetitive try/catch boilerplate across the entire codebase.
Stack Used
- Express.js
- TypeScript
- Zod
- Helmet
- Morgan
- Swagger
- express-rate-limit
1. Eliminating Boilerplate with the asyncHandler Pattern (HOF)
Writing try/catch blocks in every single controller handler clutters code and introduces human error—it’s easy to forget to pass an error to next().
To solve this, I created a Higher-Order Function (HOF) factory that wraps asynchronous request handlers and automatically catches rejected promises, safely routing them into the global error handler.
import { Request, Response, NextFunction, RequestHandler } from 'express';
export const asyncHandler = (fn: RequestHandler): RequestHandler => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
The Code Evolution: From Boilerplate to Declarative Architecture
To see why this abstraction is a massive win, look at how the controller layer evolves. Below is a side-by-side comparison of fetching a specific financial transaction using the traditional imperative approach versus our optimized, type-safe architecture.
❌ The Old Approach (Bloated, Imperative & Error-Prone)
Without a custom pipeline, every individual route handler becomes bogged down with parsing inputs, manually invoking defensive validation checks, formatting response shapes, and maintaining exception routing structures. If you miss a catch(next) path, errors may bypass Express's error middleware and result in unhandled promise rejections or inconsistent client responses:
import { Request, Response, NextFunction } from 'express';
import { transactionIdSchema } from '../validators/transaction.validator';
import { getTransactionService } from '../services/transaction.service';
import AppError from '../utils/AppError';
export const getTransaction = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
// 1. Manual validation footprint inside business controller
const parsedId = transactionIdSchema.safeParse(req.params.id);
if (!parsedId.success) {
throw new AppError(
parsedId.error.issues[0]?.message ?? 'Invalid transaction id',
400
);
}
// 2. Business logic invocation
const transaction = await getTransactionService(
parsedId.data,
req.auth.userId
);
// 3. Raw response formatting
res.status(200).json({
success: true,
data: transaction,
});
} catch (error) {
// 4. Repetitive catch block required everywhere to prevent runtime failure
next(error);
}
};
The Modern Approach (Declarative, Focused & Clean)
By pulling validation logic into our generic middleware factory and wrapping the handler in asyncHandler, the controller shrinks into a lightweight, high-signal function.
Data validation is guaranteed upstream by the routing layer, errors route down automatically, and the handler focuses strictly on orchestrating data delivery:
import { getTransactionService } from '../services/transaction.service';
import { TransactionIdType } from '../validators/transaction.validator';
import { sendSuccess } from '../utils/sendSuccess';
import { asyncHandler } from '../utils/asyncHandler';
export const getTransaction = asyncHandler(async (req, res) => {
// 1. Extract clean, pre-validated parameters safely via type assertion
const { id } = req.validated?.params as TransactionIdType;
// 2. Invoke core business logic service
const transaction = await getTransactionService(
id,
req.auth.userId
);
// 3. Standardized, reliable JSON streaming utility
sendSuccess(res, 200, transaction);
});
2. TypeScript Magic: Declaration Merging & Type Augmentation
When dealing with authentication tokens, request tracing (requestId), or custom validated payloads, developers frequently resort to casting the request as any (e.g., (req as any).userId). This completely destroys type safety.
Instead of fighting the compiler, I leveraged TypeScript Declaration Merging to reopen Express's internal Request interface and merge my custom metadata natively.
export {}; // Forces this file to be treated as a module
declare global {
namespace Express {
interface Request {
auth: {
userId: number;
email: string;
};
requestId: string;
validated?: {
body?: unknown;
query?: unknown;
params?: unknown;
};
}
}
}
Because .d.ts declaration files emit no runtime JavaScript after compilation, this provides compile-time guarantees without adding overhead to the production build.
3. The Validation Pipeline: Middleware Factories with Zod
Financial APIs accept a lot of data—transaction parameters, updates, limits. Validating inputs right inside the controller logic violates the Separation of Concerns principle.
I built a generic Middleware Factory named validate. It accepts a Zod schema and a data source destination (body, query, or params), validates the payload dynamically, safely appends the typed schema data back onto our augmented req.validated object, and automatically blocks malformed requests:
import { NextFunction, Request, Response, RequestHandler } from "express";
import { z } from "zod";
import AppError from "../utils/AppError";
type Source = 'params' | 'body' | 'query';
export default function validate<T extends z.ZodType>(schema: T, source: Source): RequestHandler {
return (req: Request, _res: Response, next: NextFunction) => {
const parsedValue = schema.safeParse(req[source]);
if (!parsedValue.success) {
return next(new AppError(parsedValue.error.issues[0]?.message ?? "Validation Error", 400));
}
if (!req.validated) {
req.validated = {};
}
req.validated[source] = parsedValue.data;
next();
};
}
Declarative Route Handling in Practice
Look how clean and readable a route becomes when combining the validate pipeline. The pipeline acts as an explicit gatekeeper—the controller code won't even execute unless the request strictly satisfies your Zod schema constraints:
router.patch(
'/transactions/:id',
validate(transactionIdSchema, 'params'),
validate(updateTransactionSchema, 'body'),
asyncHandler(updateTransaction)
);
4. Centralized Error Mapping
Rather than writing messy return res.status(400).json(...) strings throughout my controllers, I funneled all app errors into a dedicated, global Error Middleware. It inspects error instances on the fly, correctly catches operational app errors and Zod validation errors, and safely falls back to standard HTTP statuses:
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import AppError from '../utils/AppError';
const errorMiddleware = (err: unknown, _req: Request, res: Response, _next: NextFunction): void => {
console.error(err);
let statusCode = 500;
let message = 'Internal Server Error';
if (err instanceof AppError) {
statusCode = err.statusCode;
message = err.message;
} else if (err instanceof ZodError) {
statusCode = 400;
message = err.issues[0]?.message ?? 'Validation Error';
} else if (err instanceof Error) {
message = err.message;
}
res.status(statusCode).json({
success: false,
message,
});
};
export default errorMiddleware;
Before looking at the actual app.ts implementation, here's a high-level view of how requests flow through the system. Each layer has a single responsibility—security, observability, validation, business logic, or error handling—which keeps the application predictable and easy to scale.
Incoming Request
│
▼
┌─────────────────┐
│ requestId │
└─────────────────┘
│
▼
┌─────────────────┐
│ Rate Limiter │
│ Helmet │
│ CORS │
└─────────────────┘
│
▼
┌─────────────────┐
│ Logger │
└─────────────────┘
│
▼
┌─────────────────┐
│ Zod Validation │
└─────────────────┘
│
▼
┌─────────────────┐
│ asyncHandler │
└─────────────────┘
│
▼
┌─────────────────┐
│ Controller │
└─────────────────┘
│
▼
┌─────────────────┐
│ Service Layer │
└─────────────────┘
│
├─────────────► Success Response
│
▼
┌─────────────────┐
│ Global Error │
│ Middleware │
└─────────────────┘
│
▼
Error Response
5. The Symphony: Orchestrating Middleware in app.ts
Writing great middleware is only half the battle. In Express, registration order is execution order. If you place a logging middleware after your routes, it won't log anything. If you place your error handler before your routes, your application will crash on async rejections.
Here is how I wired the entry point of the application (app.ts) to ensure enterprise-grade security, optimization, and request tracing run flawlessly:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';
import healthRoutes from './routes/health.routes';
import authRoutes from './routes/auth.routes';
import transactionRoutes from './routes/transaction.routes';
import analyticsRoutes from './routes/analytics.routes';
import errorMiddleware from './middlewares/error.middleware';
import logger from './middlewares/logger.middleware';
import { globalLimiter } from './middlewares/rateLimit.middleware';
import requestIdMiddleware from './middlewares/requestId.middleware';
import swaggerSpec from './config/swagger';
const app = express();
// 1. Ingestion & Request Tracing Layer (Runs first to stamp every request)
app.use(requestIdMiddleware);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// 2. Security & Traffic Control Layer
app.use(globalLimiter);
app.use(helmet());
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
}));
// 3. Performance & Parsing Layer
app.use(compression());
app.use(express.json());
app.use(cookieParser());
// 4. Observability Layer (Must stay above routes to attach event listeners)
app.use(logger);
// 5. Business Logic / Domain Routes
app.use('/', healthRoutes);
app.use('/', authRoutes);
app.use('/', transactionRoutes);
app.use('/', analyticsRoutes);
// 6. The Safety Net (Must be registered LAST)
app.use(errorMiddleware);
export default app;
Why this specific lifecycle order matters:
-
requestIdMiddlewaresits at the absolute top: Every request needs a unique UUID stamped onto it immediately so that our downstream observability tools and logs can group asynchronous operations cleanly. -
loggersits right above the routes: Logging tools like Morgan attach event listeners directly to the Node.js HTTP response lifecycle. Placing it right above the routes guarantees every incoming API request is caught and tracked chronologically. -
errorMiddlewaresits at the absolute bottom: Express skips standard middleware whennext(err)is invoked, hunting downstream exclusively for a function signature with 4 parameters(err, req, res, next). If any route throws an error, it drops straight down into this safety net.
Conclusion
The goal wasn't simply to make the code cleaner—it was to make the system easier to reason about, safer to extend, and harder to break as new features are added.
By combining Declaration Merging, Higher-Order Functions, and Schema-Driven validation factories, the architecture of this personal finance backend delivers several engineering benefits:
-
Near-zero unhandled async route errors: The
asyncHandlerpattern eliminates a major class of unhandled async route errors. -
Type safety:
req.authandreq.validatedremain type-safe and discoverable throughout the request lifecycle. - Reduced bug surface area: Code reviewers can clearly audit validation criteria directly from route declarations without digging through controller implementations.
Note: Express 5 includes native async error propagation. The
asyncHandlerpattern remains valuable for Express 4 projects and teams that prefer explicit handler wrapping.
💬 Over to You:
How do you usually structure your Express + TypeScript applications? Do you prefer declaration merging for extending the Request object, or do you pass custom context through another pattern?
Let me know your thoughts, critiques, or alternative approaches in the comments below.
Top comments (0)