The Most Overlooked Quality Signal in APIs
- Zod — runtime schema validation with TypeScript inference
- `validate()` middleware — a clean, reusable wrapper pattern
- Custom `AppError` class — structured error hierarchy
- RFC 9457 Problem Details — the industry-standard error response format
- Structured logging — Pino and how to make logs actually useful
Why TypeScript Alone Isn't Enough
This is a question I get a lot: “I already have TypeScript. Why do I need Zod?”
TypeScript types exist only at compile time. When a request hits your Express API, the body is `any` at runtime — TypeScript cannot protect you from:
- A missing required field
- A string where you expected a number
- An SQL injection attempt disguised as a valid-looking body
- A negative value for a field that should always be positive
"TypeScript helps you catch mistakes at compile time, but it can't validate data when your code is actually running. That's where a schema validation library like Zod shines."
Steve Kinney, Full Stack TypeScript
Schema Validation With Zod
Install
npm install zod
Define Schemas
// src/schemas/user.schema.ts
import { z } from 'zod';
export const createUserSchema = z.object({
body: z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
email: z.string().email('Must be a valid email address'),
age: z.number().int().min(13, 'Must be at least 13 years old').optional(),
role: z.enum(['user', 'admin', 'editor']).default('user'),
}),
});
export const updateUserSchema = z.object({
params: z.object({
id: z.coerce.number().int().positive(), // Coerce string param to number
}),
body: z.object({
name: z.string().min(2).max(100).optional(),
role: z.enum(['user', 'admin', 'editor']).optional(),
}),
});
export const getUsersSchema = z.object({
query: z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['user', 'admin', 'editor']).optional(),
}),
});
// Infer TypeScript types from schemas — no duplicate type definitions
export type CreateUserBody = z.infer<typeof createUserSchema>['body'];
export type UpdateUserParams = z.infer<typeof updateUserSchema>['params'];
Note on Zod v4
The `validate()` Middleware Wrapper
The key insight is building a factory middleware that accepts any Zod schema and returns a middleware function. One pattern, works everywhere.
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validate = (schema: AnyZodObject) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Validates body, query, and params in one pass
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (err) {
if (err instanceof ZodError) {
// Format Zod errors into RFC 9457 Problem Details
return res.status(422).json({
type: 'https://api.example.com/errors/validation-error',
title: 'Validation Error',
status: 422,
detail: `${err.issues.length} validation error(s) found`,
errors: err.issues.map((issue) => ({
field: issue.path.slice(1).join('.'), // Remove 'body'/'query' prefix
message: issue.message,
code: issue.code.toUpperCase(),
})),
});
}
next(err);
}
};
};
Apply it directly in your routes:
// src/routes/user.routes.ts
import { Router } from 'express';
import { validate } from '../middleware/validate.js';
import { createUserSchema, updateUserSchema, getUsersSchema } from '../schemas/user.schema.js';
const router = Router();
router.get('/', validate(getUsersSchema), getUsers);
router.post('/', validate(createUserSchema), createUser);
router.put('/:id', validate(updateUserSchema), updateUser);
export default router;
A Custom `AppError` Class — Structure Your Errors
// src/errors/AppError.ts
export type ErrorCode =
| 'NOT_FOUND'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'CONFLICT'
| 'INTERNAL_ERROR';
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: ErrorCode;
public readonly isOperational: boolean; // Expected error vs programming bug
constructor(
message: string,
statusCode: number = 500,
code: ErrorCode = 'INTERNAL_ERROR',
isOperational: boolean = true
) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
// Convenience subclasses
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
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');
}
}
Usage becomes clean and self-documenting:
import { NotFoundError, ConflictError } from '../errors/AppError.js';
async function createUser(req, res) {
const existing = await userRepo.findByEmail(req.body.email);
if (existing) throw new ConflictError('Email already registered');
const user = await userRepo.create(req.body);
res.status(201).json(user);
}
async function getUser(req, res) {
const user = await userRepo.findById(Number(req.params.id));
if (!user) throw new NotFoundError('User');
res.json(user);
}
RFC 9457 Problem Details
The Standard Format
{
"type": "https://api.yourapp.com/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "User with ID 42 was not found.",
"instance": "/api/v1/users/42"
}
Centralized Error Handler Middleware
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError.js';
import { logger } from '../config/logger.js';
const BASE_ERROR_URL = 'https://api.yourapp.com/errors';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
// Operational errors: expected, safe to return details
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
type: `${BASE_ERROR_URL}/${err.code.toLowerCase().replace('_', '-')}`,
title: err.message,
status: err.statusCode,
detail: err.message,
instance: req.path,
});
}
// Unknown/programming errors: log full details, return safe message
logger.error({ err, req: { method: req.method, path: req.path } }, 'Unhandled error');
res.status(500).json({
type: `${BASE_ERROR_URL}/internal-error`,
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred. Our team has been notified.',
instance: req.path,
});
}
Register it last in your app:
// src/index.ts — after all routes
app.use(errorHandler);
Never leak stack traces in production
Structured Logging With Pino
Pino
Register it last in your app:
npm install pino pino-http
npm install --save-dev pino-pretty
// src/config/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
...(process.env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'HH:MM:ss Z' }
}
})
});
Add request logging middleware
// src/index.ts
import pinoHttp from 'pino-http';
import { logger } from './config/logger.js';
app.use(pinoHttp({ logger }));
// Every request now gets a structured log: method, url, statusCode, responseTime
```
Log structured events in your services:
```typescript
import { logger } from '../config/logger.js';
export async function createUser(data: CreateUserBody) {
logger.info({ email: data.email }, 'Creating new user');
const user = await userRepo.create(data);
logger.info({ userId: user.id }, 'User created successfully');
return user;
}
Your logs now look like
{
"level": 30,
"time": 1720000000000,
"msg": "User created successfully",
"userId": 42
}
{
"level": 40,
"time": 1720000001000,
"msg": "Unhandled error",
"err": {
"type": "Error",
"message": "..."
},
"req": {
"method": "POST",
"path": "/api/v1/users"
}
}
These are parseable by Datadog, CloudWatch, Loki, and every other log aggregation tool.
Explore project snapshots or discuss custom web solutions.
Make it work, make it right, make it fast — in that order. 'Right' includes clear error messages.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Zod is TypeScript-first — schemas and types are defined once, and TypeScript infers types automatically. Joi and express-validator require separate type definitions. As of 2026, Zod is the dominant choice in the TypeScript ecosystem with 40M+ weekly downloads.
In high-stakes environments (financial APIs, medical data), yes — use Zod to validate your outbound response shapes as well. This catches bugs where your service returns unexpected data. For most APIs, validating input is sufficient.
An operational error is expected: "user not found", "email already taken", "invalid input". A programming error is a bug: `TypeError: Cannot read properties of undefined`. Operational errors can be returned to clients; programming errors should be logged and return a generic 500.
RFC 9457 replaced RFC 7807 in September 2023. It's backward-compatible — RFC 7807 responses are still valid — but RFC 9457 adds clarifications and additional guidance. Use RFC 9457 for new APIs.
Pino is significantly faster and outputs JSON by default, making it the better choice for production APIs. Winston is more flexible and has a larger plugin ecosystem, making it useful if you need custom transports. For most Express APIs, Pino is the right call.
Comments are closed