Error Response Standards
Consistent error responses help API clients branch on code, show localized messages, and log incidents. RFC 9457 Problem Details (application/problem+json) is the standard envelope; stable error codes survive copy changes.
Recipe
Quick-reference recipe card - copy-paste ready.
// application/errors.ts
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
public readonly detail?: string,
public readonly i18nKey?: string
) {
super(detail ?? code);
}
}
// infrastructure/http/problem.ts
export function toProblem(err: AppError) {
return {
type: `https://api.example.com/errors/${err.code}`,
title: err.code,
status: err.status,
detail: err.detail,
code: err.code,
i18nKey: err.i18nKey ?? `errors.${err.code}`,
};
}HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/ORDER_NOT_FOUND",
"title": "ORDER_NOT_FOUND",
"status": 404,
"detail": "Order ord_99 not found",
"code": "ORDER_NOT_FOUND",
"i18nKey": "errors.order_not_found"
}When to reach for this:
- Mobile and web clients map errors to UI strings
- Support teams search logs by
code, not message text - Public B2B API documents error catalog in OpenAPI
- You need consistent shape across Express, workers, and webhooks
Working Example
// src/errors/app-error.ts
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
public readonly detail?: string,
public readonly meta?: Record<string, unknown>
) {
super(detail ?? code);
this.name = "AppError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super("NOT_FOUND", 404, `${resource} ${id} not found`, { resource, id });
}
}
// src/http/problem.ts
import type { AppError } from "../errors/app-error";
export type ProblemDetails = {
type: string;
title: string;
status: number;
detail?: string;
code: string;
i18nKey: string;
meta?: Record<string, unknown>;
};
const ERROR_BASE = "https://api.acme.example/errors";
export function toProblem(err: AppError): ProblemDetails {
return {
type: `${ERROR_BASE}/${err.code}`,
title: err.code,
status: err.status,
detail: err.detail,
code: err.code,
i18nKey: `errors.${err.code.toLowerCase()}`,
meta: err.meta,
};
}
// src/http/error-middleware.ts
import type { ErrorRequestHandler } from "express";
import { AppError } from "../errors/app-error";
import { toProblem } from "./problem";
export const errorMiddleware: ErrorRequestHandler = (err, req, res, _next) => {
if (err instanceof AppError) {
const problem = toProblem(err);
return res.status(err.status).type("application/problem+json").json(problem);
}
req.log?.error({ err }, "unhandled error");
const status = 500;
res.status(status).type("application/problem+json").json({
type: `${ERROR_BASE}/INTERNAL_ERROR`,
title: "INTERNAL_ERROR",
status,
code: "INTERNAL_ERROR",
i18nKey: "errors.internal",
detail: process.env.NODE_ENV === "production" ? undefined : String(err),
});
};
// use case
import { NotFoundError } from "../errors/app-error";
export async function getOrder(repo: { findById: (id: string) => Promise<unknown> }, id: string) {
const order = await repo.findById(id);
if (!order) throw new NotFoundError("Order", id);
return order;
}What this demonstrates:
- Use cases throw
AppErrorsubclasses with stablecode - One middleware maps all errors to Problem Details JSON
- Production 500 responses omit internal exception strings
i18nKeylets clients localize without parsing Englishdetail
Deep Dive
How It Works
- Problem Details fields:
type(URI),title,status,detail, extensions (code,i18nKey) - Validation errors add
errors[]withfieldandmessageextensions - Logging: log full stack server-side; never include
stackin JSON body in prod - OpenAPI: document each
codeunderresponses.4xx.content
Error Code Catalog (Example)
| code | HTTP | i18nKey | When |
|---|---|---|---|
VALIDATION_ERROR | 400 | errors.validation | Zod fail |
UNAUTHORIZED | 401 | errors.unauthorized | Missing token |
FORBIDDEN | 403 | errors.forbidden | Insufficient role |
NOT_FOUND | 404 | errors.not_found | Resource missing |
CONFLICT | 409 | errors.conflict | Duplicate idempotency |
RATE_LIMITED | 429 | errors.rate_limited | Too many requests |
Validation Error Shape
{
"type": "https://api.example.com/errors/VALIDATION_ERROR",
"title": "VALIDATION_ERROR",
"status": 400,
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "qty", "message": "Number must be greater than 0" }
]
}TypeScript Notes
// Result type alternative - explicit errors without throw
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };Gotchas
- String matching on
detail- Copy changes break clients. Fix: Branch oncodeonly. - 200 with error payload - Legacy mobile hack. Fix: Use proper status codes for new APIs.
- Leaking Prisma errors -
Unique constraint failed on...in body. Fix: Map toCONFLICTin middleware. - Different shapes per route -
{ message }vs{ error }. Fix: One middleware, one schema. - i18n on server - Translating
detailserver-side for every locale. Fix: Sendi18nKey; client translates.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| GraphQL errors array | GraphQL API | REST B2B standard |
{ error: { code } } envelope | Simpler internal APIs | Need RFC-standard tooling |
| HTTP status only | Scripts | Rich client UX |
| gRPC status codes | gRPC services | JSON REST clients |
FAQs
Problem Details or custom envelope?
Problem Details for public APIs and partners. Internal { error: { code } } OK if documented and consistent.
Should validation use 400 or 422?
Pick one org-wide. 400 is common; 422 Unprocessable Entity is semantically precise for body shape errors.
How do clients handle unknown codes?
Fall back to generic message via i18nKey prefix errors.unknown and log code to support.
Fastify error handler?
setErrorHandler maps AppError to Problem Details - same types as Express middleware.
NestJS exception filters?
@Catch(AppError) filter returns Problem Details; register globally.
Include requestId in errors?
Yes as extension field requestId - helps support correlate user report to logs.
Document errors in OpenAPI?
Yes - responses.404.content.application/problem+json.schema with code enum where finite.
Retryable errors?
Add extension retryable: true for 503/429 with Retry-After header.
Sensitive details in 403?
Avoid "user exists but wrong tenant." Use generic FORBIDDEN to prevent enumeration.
Worker job failures?
Same code in job result payload; HTTP adapter and worker share AppError types.
Related
- API Design Basics - envelopes and status codes
- OpenAPI & Swagger - document error schemas
- Error Handling in Express - middleware ordering
- Security - safe error leakage
- Logging Basics - log errors with context
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.