Input Validation and Error Handling Done Right

Front
Back
Right
Left
Top
Bottom
OVERLOOKED

The Most Overlooked Quality Signal in APIs

Here’s a quick test: send a bad request to your API right now. What do you get back? If the answer is `500 Internal Server Error`, a raw stack trace, or an inconsistent JSON shape — your API has a quality problem that no amount of feature work will fix. Validation and error handling aren’t “extra features.” They’re the foundation of a trustworthy API. A developer integrating with your API judges it by how predictably it behaves when things go wrong — not just when they go right.
This post covers the full stack:
TYPESCRIPT

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:

"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

ZOD

Schema Validation With Zod

Install
Copy to clipboard
npm install zod
Define Schemas
Copy to clipboard
// 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
In Zod v4, `z.string().email()` is preferably `z.email()` and `z.string().uuid()` becomes `z.uuid()`. The v4 API is largely backwards-compatible but cleaner.
VALIDATE()

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.

Copy to clipboard
// 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:
Copy to clipboard
// 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;
Now every route is validated before the handler runs. If validation fails, the client gets a clear, structured error immediately.
APPERROR

A Custom `AppError` Class — Structure Your Errors

Not all errors are equal. A “user not found” (404) is fundamentally different from a database connection failure (500). Your error class should reflect that.
Copy to clipboard
// 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:
Copy to clipboard
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
The Industry Standard Error Format

RFC 9457 Problem Details

RFC 9457 (formerly RFC 7807) defines a standard format for HTTP API error responses. As of 2026, this is the most widely adopted error format across major APIs including Stripe, GitHub, and Cloudflare.
The Standard Format
Copy to clipboard
{
  "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
Copy to clipboard
// 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:
Copy to clipboard
// src/index.ts — after all routes
app.use(errorHandler);
Never leak stack traces in production
Your error handler should log the full error server-side (with trace IDs) and return only safe, generic messages to the client.
PINO

Structured Logging With Pino

`console.log` in production is a disaster. You can’t filter by severity, correlate across requests, or parse machine-readable logs with a dashboard.
Pino
is the fastest structured logger for Node.js — it outputs JSON and has minimal performance impact.
Register it last in your app:
Copy to clipboard
npm install pino pino-http
npm install --save-dev pino-pretty
Copy to clipboard
// 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
Copy to clipboard
// 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
Copy to clipboard
{
    "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.

Kent Beck, Test-Driven Development

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!
Front
Back
Right
Left
Top
Bottom
FAQ's

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