Skip to content

9.9.3 — Error Architecture & Validation

Two things arrive at your handler from outside: data you did not write, and failures you did not plan for. This page is about both, because they turn out to be the same discipline pointed in two directions.

The failure half asks: when something goes wrong anywhere in the request — in validation, in the database driver, in a third-party call, in a plain bug — where does it end up, and what does the client see? The data half asks: how do bytes off the network become values your code is allowed to trust?

Get both right once, in one place each, and every handler in the app gets shorter.

1. The error pipeline and the four-argument handler

Express keeps two chains: the normal one and an error one. next(err)next called with any argument — abandons the normal chain and looks for the next middleware whose function takes four parameters: How does Express handle errors? [EQ-167]Why must error middleware have 4 arguments? [EQ-169]

javascript
app.use((err, req, res, next) => {          // FOUR params ⇒ Express treats this as an
  /* the single funnel for every failure */ // error handler. Three params ⇒ normal
});                                          // middleware, never invoked with an error.

The mechanism behind this is ordinary JavaScript, and knowing it makes the rule stop feeling arbitrary. Every function has a length property holding how many parameters it declares (3.6.1). When you register a middleware, Express reads that number. Four means "this is an error handler". Anything else means "this is a normal middleware".

Two things follow, and both produce bugs with no message attached.

You cannot drop the unused next. Writing (err, req, res) => { ... } feels tidy, because you never call next inside it. But its length is 3, so Express files it as a normal middleware, which means it will never be called with an error. Your error handler is now a piece of code that simply never runs, and nothing anywhere says so.

Default values and rest parameters also change length. A parameter with a default is not counted, so (err, req, res, next = noop) also has a length of 3, and fails the same silent way. If you use TypeScript, remember that its _next naming convention for unused parameters is fine — the name does not matter, only the count.

Register error handlers last, after every route and after the 404 handler. Express searches forward from wherever the error happened, so an error handler registered above your routes will never see errors thrown inside them.

Operational vs programming errors — the distinction that decides behavior (3.8.5 drew it at process level; here it decides the response): Difference between operational and programming errors? [EQ-173]

Operational errors are things that go wrong in a perfectly correct program. Someone submitted an invalid email. The order they asked for does not exist. Their token expired. The card was declined. A service you depend on timed out. None of these mean your code is broken — they mean the world is the way it is. You respond with the right status and a machine-readable code, and the process carries on completely healthy.

Programming errors are bugs. Cannot read property 'map' of undefined. A case you never handled. An assumption that was false. Here the correct response is a 500 that says nothing revealing, a log entry with everything you know, and a small amount of suspicion about the process itself.

That last part deserves an explanation, because "consider the process suspect" sounds dramatic. When code throws somewhere you never expected, you do not know how far it got. It may have written half of a two-step update. It may hold a lock it will never release, or a database transaction it will never close. The program's internal state is now in a shape you never designed and cannot reason about. This is why the Node convention (3.8.5) is to let a process die and be restarted rather than let it limp: a fresh process is in a state you understand, and a limping one silently corrupts things for hours.

Encode the distinction in the type system, not in comments — a custom error base carrying its own HTTP semantics: How do you create custom error classes? [EQ-172]

typescript
export abstract class AppError extends Error {
  abstract readonly status: number;
  abstract readonly code: string;             // stable machine code (9.6.1) — clients branch
  readonly isOperational = true;               // ← the classifier the handler reads
  constructor(message: string, readonly details?: unknown) {
    super(message);
    Error.captureStackTrace?.(this, this.constructor);   // drop the constructor frame
  }
}
export class NotFoundError extends AppError {
  readonly status = 404; readonly code = "NOT_FOUND";
  constructor(resource: string, id: string) { super(`${resource} ${id} not found`); }
}
export class ValidationError extends AppError {
  readonly status = 422; readonly code = "VALIDATION_FAILED";
}
export class ForbiddenError extends AppError { readonly status = 403; readonly code = "FORBIDDEN"; }
// …ConflictError 409, RateLimitedError 429, UpstreamTimeoutError 504

Anything that is not an AppError arriving at the handler is, by construction, a programming error — which makes the classification mechanical rather than judgmental.

2. The async trap — and its structural fix

Express 4's router is synchronous; it has no idea your handler returned a promise. So:

javascript
app.get("/orders/:id", async (req, res) => {            
  const order = await orders.find(req.params.id);       // if this REJECTS…
  res.json(order);                                       // …nothing catches it:
});                                                      // no error handler, no response,
                                                         // the request HANGS (9.9.1) and
                                                         // Node logs an unhandled rejection

Read that carefully, because the failure is quieter than it looks. The promise rejects. Nothing is listening. Express never learns anything happened, so it does not call your error handler and does not send a response. The client sits there. Node prints an unhandled rejection warning somewhere, which nobody is watching, and on newer versions it may terminate the whole process instead. The request that hangs and the process that dies are the same bug.

There are three fixes, and they are not equally good.

Wrap every handler body in try/catch and call next(e). This works. It is also a rule enforced by human memory, which means it holds for eleven handlers and fails on the twelfth — reliably the one doing something important.

Use a wrapper function. This is the standard Express 4 answer:

javascript
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);       // rejection ⇒ next(err) ✅

app.get("/orders/:id", asyncHandler(async (req, res) => {
  res.json(await orders.find(req.params.id));            // throws land in the error pipeline
}));

Promise.resolve(...) handles both cases in one line: if the handler is async you get its promise back, and if it is an ordinary function you get a resolved promise. Either way, .catch(next) sends any rejection into the error pipeline. The cost is that every async route must be wrapped, and forgetting the wrapper is invisible until that route throws.

The package express-async-errors removes the ceremony by patching the router itself, so a single import at the top of your app makes every async handler behave this way. You trade explicitness for a guarantee that nobody can forget — usually a good trade, as long as the import is somewhere obvious and commented, because a new team member will otherwise wonder why nothing is wrapped.

Upgrade to Express 5. Its router awaits handler promises and forwards rejections to next(err) itself, so the trap does not exist. If you are starting fresh, this is the answer. How do you handle async errors? [EQ-170]What is express-async-errors? [EQ-171]

Whichever you pick, pick exactly one and make a lint rule enforce it. A codebase where some async handlers are wrapped and some are not is worse than one where none are, because the inconsistency guarantees nobody checks.

3. The centralized handler

One handler, at the end, owning the whole failure contract — this is what makes 9.6.1's "one error envelope for every failure source" true rather than aspirational: What is centralized error handling? [EQ-174b]

typescript
app.use((req, res) => {                                   // 404 handler: no route matched
  res.status(404).json(problem({ code: "NOT_FOUND", status: 404, traceId: res.locals.traceId }));
});

app.use((err, req, res, next) => {                        // THE error handler — 4 args, last
  if (res.headersSent) return next(err);                  // (1) already streaming → delegate
                                                          //     to Express's default (closes it)
  const operational = err instanceof AppError;            // (2) classify (section 1)
  const status = operational ? err.status : 500;
  const traceId = res.locals.traceId;

  logger[operational && status < 500 ? "warn" : "error"](  // (3) log ONCE, here, with context
    { err, traceId, route: req.route?.path, userId: req.user?.id, status },
    err.message,
  );

  res.status(status).json(problem({                       // (4) ONE envelope shape (9.6.1)
    code: operational ? err.code : "INTERNAL_ERROR",
    status,
    detail: operational ? err.message : undefined,        // (5) NEVER leak internals for 5xx
    errors: operational ? err.details : undefined,        //     (no stack, no SQL, no paths)
    traceId,                                              // (6) the support/log bridge
  }));

  if (!operational) process.emitWarning("non-operational error served");  // optional: alert path
});

Four of those decisions are worth defending in detail.

(1) The headersSent check. Once any bytes of the response have gone out, the status line is already on the wire and cannot be changed. If an error happens halfway through streaming a large response, there is no way to turn it into a 500 — the client has already been told 200. Handing it back to Express lets it destroy the connection, which makes the client see a broken transfer and retry, rather than receiving a truncated body that looks complete.

(3) Log in exactly one place. The tempting alternative is logging where the error was noticed and then rethrowing, which produces two entries for one failure and, at scale, makes your log search useless. It also loses information: the handler in the middle of the stack knows less than the funnel does, which has the route, the user, the trace ID, and the final status all in hand.

(5) Never let a 500 say anything. For an unexpected error, the response body should carry the code INTERNAL_ERROR, a trace ID, and nothing else. A stack trace tells a stranger your file paths, your directory layout, the libraries and versions you run, and sometimes a fragment of a query with a table name in it (Part 8.5). None of that helps a legitimate client and all of it helps an attacker plan. The log keeps everything; the response keeps nothing.

(6) The trace ID is what makes rule (5) tolerable. Without it, hiding details from users means support cannot help them. With it, a user quotes eight characters from an error screen, support pastes it into the log search, and the full stack trace is right there. 3.8.7 shows how AsyncLocalStorage carries that ID through code that never touches req. How do you avoid exposing stack traces? [EQ-175]

validation → 422param loader → 404handler throw → ???upstream timeout → 504next(err)CENTRALIZED HANDLERheadersSent? → delegateclassify · log once · envelope(err, req, res, next) — 4 argsOPERATIONAL (AppError)status + code + details + traceIdPROGRAMMING (anything else)500 + INTERNAL_ERROR + traceIdfull context to LOGS only
Figure 1 — Every failure, one funnel. Any layer diverts with next(err); the last four-argument handler classifies operational (typed AppError: real status, real code, safe detail) versus programming (500, opaque body, full context to logs), logs exactly once, and emits the single envelope every client parses.

4. Validation: unknown until proven

Every inbound byte — body, params, query, headers — is attacker-controlled and arrives as strings or untyped JSON (3.7.7's boundary law, now at the HTTP edge). Validation middleware converts claims into typed facts, and the natural spelling makes the parsed result replace the raw input: Validation with Joi/Zod/express-validator. [EQ-560b]

typescript
const CreateOrder = z.object({
  body: z.object({
    items: z.array(z.object({ sku: z.string().min(1), qty: z.number().int().positive() })).min(1),
    couponCode: z.string().regex(/^[A-Z0-9]{4,12}$/).optional(),
  }),
  params: z.object({ customerId: z.string().uuid() }),
  query: z.object({ dryRun: z.coerce.boolean().default(false) }),   // query = strings → coerce
});

const validate = (schema: ZodSchema) => (req, res, next) => {
  const result = schema.safeParse({ body: req.body, params: req.params, query: req.query });
  if (!result.success) {
    return next(new ValidationError("invalid request", toFieldErrors(result.error)));  // 422 +
  }                                                                    // FIELD-LEVEL details
  Object.assign(req, result.data);        // parsed+typed values REPLACE raw ones
  next();
};

router.post("/:customerId/orders", validate(CreateOrder), asyncHandler(createOrder));

Four rules are built into that code, and each one prevents a specific class of bug.

Validate all four sources, not just the body. Bodies get all the attention because that is where the interesting data is. Meanwhile path parameters go straight into database lookups, query values go into sort clauses, and headers get logged and sometimes trusted. Those are the paths bad input actually walks in through, precisely because nobody was watching them.

Replace the raw values with the parsed ones, do not merely check them. This is the difference between a validator and a parser, and it matters more than it sounds. If validation only checks and leaves req.query.page as the string "2", then every handler has to convert it again, and they will do it slightly differently — one uses Number(), one uses parseInt, one forgets and does arithmetic on a string (3.6.7). Overwriting the raw values with parsed ones means numbers are numbers by the time any handler sees them, and defaults are already filled in.

Strip keys you did not ask for. Zod objects do this automatically, and it is a real defence rather than tidiness. Consider a handler that takes the validated body and passes it straight to a database update. A client sends {"name": "Ann", "isAdmin": true}. If unknown keys survive validation, that extra field rides along into the update and the user just promoted themselves. Stripping unknown keys means the shape reaching your data layer contains only fields you declared.

Produce errors per field. The schema already knows exactly which field failed and why, so turning that into an errors[] array costs one function. This is what lets the frontend highlight the two bad inputs instead of showing a single unhelpful banner (9.6.1 explains why that matters to the person filling in the form).

Choosing a library

Zod is the default for new TypeScript services, for one decisive reason: z.infer derives the static type from the same object that performs the runtime check (3.7.7). You cannot have a type that disagrees with your validation, because there is only one definition. The same object can generate your OpenAPI document too (9.6.4).

Joi is older and very good at rules that span several fields — "this field is required only when that other one is set". Its weakness in a TypeScript codebase is that the types are written separately from the schema, so the two can drift apart with nothing to notice.

express-validator takes a different shape: chains of middleware like body("email").isEmail().normalizeEmail(). It also ships sanitizers, which the schema libraries deliberately do not.

That last word is worth pinning down, because the two get confused constantly. Validation rejects input that does not match your contract. Sanitization transforms input into a normalized form — trimming spaces, lowercasing an email, stripping HTML tags.

Reject by default. Sanitize only where the domain genuinely wants a normalized value, such as email addresses or phone numbers, and where quietly changing what the user typed is not a surprise. And never use sanitization as your defence against cross-site scripting. Stripping tags on the way in cannot know every context the value will later be rendered in; XSS is fixed by encoding on the way out, at the point of rendering (Part 8.5).

5. The expert lens

One funnel is what turns an error contract from a promise into a fact. Scatter try/catch blocks through your handlers and each one invents its own response shape, because whoever wrote it was thinking about that endpoint and not about the API. The result is what 9.6.1 warned about: the router's 404, validation's 422, and a crash's 500 all parse differently, so clients write three parsers and then break on the fourth shape you ship.

Centralising gives you exactly one place that decides the envelope, one place that logs, one place that enforces the leak rule, and therefore one place to change when any of it is wrong. That is 9.1's cohesion applied to failure: the error contract is a module, and modules live in one file.

Classify with types so the response is mechanical. err instanceof AppError replaces a judgement call — "is this an expected failure or a bug?" — with a fact decided at the place the error was created. Nobody has to be wise at the moment of catching.

This buys something further. Your service code can throw new ConflictError("seat already taken") while knowing nothing about HTTP, and the funnel translates it into a status and a code at the edge. The domain speaks its own language and the adapter does the translating, which is the dependency-inversion arrow of 9.3.9 applied to failures.

Two team rules keep the classification honest: never throw a bare Error in domain code, because everything untyped is treated as a bug; and never build an HTTP response inside a service, because that is the edge's job and doing it inside blurs the line you just drew.

Validation is where types meet the network, and it should be one artifact. The runtime check, the TypeScript type (3.7.7), and the published OpenAPI schema (9.6.4) all describe the same thing. Written by hand, they are three descriptions that agree today and disagree by next quarter, and the disagreement is discovered by a client.

Derive all three from one schema and adding a field becomes a single edit that updates the check, the type, the documentation, and the mock server together.

What the interviewer will push on

This is the highest-yield page in the folder for interviews, because error handling is where candidates most often reveal that they have only built happy paths.

"Why does error middleware need four arguments?" They want the mechanism, not the rule. Express reads fn.length at registration time and treats four-parameter functions as error handlers. The follow-up that separates people: what happens if you drop the unused next? The answer is that it silently becomes a normal middleware and your error handling stops existing, with no warning anywhere.

"Your async handler throws. What happens?" In Express 4, nothing good: the router never sees the rejection, so no error handler runs, no response is sent, and the request hangs while Node reports an unhandled rejection nobody is reading. Then give the structural fix — a wrapper, the patching package, or Express 5 — and say why per-handler try/catch is the weakest of the options even though it is technically correct.

"How do you decide between responding and crashing?" They are looking for the operational-versus-programming split. Expected conditions get a status code and the process stays up. Bugs get a 500, a full log entry, and genuine suspicion about process state, because after an unexpected throw you do not know what was left half-done. The strong version mentions encoding the distinction in a base error class so classification is mechanical rather than a judgement call in every catch block.

"What goes in a 500 response body?" A code, a trace ID, and nothing else. They are checking whether you know that stack traces leak file paths, library versions, and sometimes query fragments. The answer that lands is the pair: nothing in the response, everything in the log, joined by the trace ID that the user can read off their screen.

"Where do you validate, and what do you validate?" All four sources — body, params, query, headers — at the edge, with the parsed values replacing the raw ones. The detail that shows real experience is stripping unknown keys, and the reason: a client adding isAdmin: true to a profile update must not have that field survive into your database layer.

"What's the difference between validation and sanitization?" Validation rejects, sanitization rewrites. The point worth adding unprompted is that neither one is your XSS defence, because whether a string is dangerous depends on where it gets rendered, and that is decided at output time, not input time.

Volunteer this one, because nobody asks: say that the error handler must check res.headersSent first. Once a response has started streaming you cannot change its status, so the only correct move is to hand the error back to Express and let it destroy the connection. Almost every hand-written error handler misses this, and the symptom — a truncated body that the client believes is complete — is genuinely hard to trace back.

Next: 9.9.4 — the request's other payloads: file uploads with Multer, structured logging, and how sessions are actually wired.

Recall

  • Express keeps a second, error chain: next(err) skips normal middleware to the first handler with four parameters — detected via fn.length, so (err, req, res) (arity 3) is silently a normal middleware. Register last, after routes and the 404 handler.
  • Operational errors (expected: 400/401/403/404/409/422/429/504) → respond, process healthy. Programming errors (bugs) → 500, opaque body, full context to logs, process suspect. Encode it in a typed AppError base (status, stable code, isOperational); everything else is a bug by construction.
  • Async trap: Express 4's router ignores returned promises — a rejection means no error handler, no response, a hung request. Fixes: per-handler try/catch, an asyncHandler wrapper (Promise.resolve(fn(...)).catch(next)), express-async-errors (global monkey-patch), or Express 5 (native). Pick one; lint it.
  • Centralized handler = the error contract's one home: headersSent → delegate; classify; log exactly once with route/user/traceId; emit one envelope (9.6.1) with a stable code; never leak stacks/SQL/paths on 5xx — the traceId bridges user report to full log.
  • Validation: parse body, params, query, headers as unknown; replace raw values with parsed ones; strip unknown keys (mass-assignment defense); field-level errors for the UI. Zod (types + OpenAPI from one schema — TS default) vs Joi (expressive cross-field, separate types) vs express-validator (chains + sanitizers). Validation rejects; sanitization transforms; XSS is fixed at output encoding.

Self-test: Why does (err, req, res) never receive errors? Classify: expired JWT, undefined.map, upstream 504, duplicate email on signup. Show the async-rejection failure and the wrapper that fixes it. Name the six decisions in the centralized handler. Which four request sources must be validated, and why "replace, don't check"?

Quiz Bank

FoundationalHow does Express's error pipeline work, and why must the handler take exactly four parameters?

Express maintains a normal middleware chain and an error chain. Calling next(err) with any argument abandons the normal chain and searches forward for the next middleware registered with four parameters(err, req, res, next). The detection mechanism is JavaScript's fn.length (declared arity, read at registration — 3.6.1): four means error handler, anything else means normal middleware. Practical consequences: omitting the unused next ((err, req, res) => …) yields arity 3, so the function silently becomes a normal middleware that never sees an error — a bug that produces no message; the same happens with default parameters or rest args, which reduce length. Error handlers must be registered last, after all routes and the 404 handler, because the search only moves forward from the error's origin. And errors thrown synchronously inside handlers are caught by Express automatically (it wraps handler invocation in try/catch); promise rejections in Express 4 are not — that's section 2's trap.

FoundationalOperational vs programming errors: define both, classify four examples, and say how the distinction changes behavior.

Operational errors are anticipated conditions in a correct program — the environment or the caller misbehaving: invalid input (422), unauthenticated (401), forbidden (403), not found (404), conflict (409), rate limited (429), upstream timeout (504). Behavior:

respond with the accurate status and a stable machine code; the process is fine, and these should not page anyone. Programming errors are defects — undefined is not a function, a violated invariant, an unhandled union case. Behavior: 500 with an opaque body, full context to logs, alert; and treat the process as suspect for anything that escaped to the top (3.8.5's fail-fast).

Classifying the four: expired JWT → operational (401, expected daily); undefined.map → programming (bug, 500); upstream 504 → operational (dependency failure is a designed-for condition — retry/circuit-break, respond 504 or degrade); duplicate email on signup → operational (409, a user outcome, not an exception in the pejorative sense). The mechanism that makes classification cheap: a typed AppError base with status/code/isOperational, so the handler's test is err instanceof AppError and anything else is a bug by construction — no judgment at 3 a.m.

AppliedShow the async-error trap and all three fixes, with the trade-off of each.

Trap (Express 4): app.get("/x", async (req, res) => { const d = await load(); res.json(d); }) — if load() rejects, the returned promise rejects, and the router (synchronous) never looks at it: next(err) is never called, the error handler never runs, no response is ever sent, and the request hangs until the client times out (9.9.1's silent-hang bug) while Node logs an unhandled rejection.

Fix 1 — try/catch per handler: explicit and dependency-free; fails the moment someone forgets, and the forgetting is invisible in review. Fix 2 — the wrapper: const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next) — one utility, applied per route; trade-off is ceremony at every route and the same forgetting risk (mitigate with a lint rule or a route-registration helper that wraps automatically).

Fix 2b — express-async-errors: a single import monkey-patches the router so every async handler's rejection routes to next(err); zero ceremony, at the cost of action-at-a-distance (a new reader can't see why it works). Fix 3 — Express 5: the router awaits handler promises and forwards rejections natively — the trap deleted at the framework level, and the reason "upgrade to 5" is a real answer to this interview question. Whatever the choice, enforce one codebase-wide; a mix means an unwrapped route is a landmine indistinguishable from a wrapped one.

InterviewDesign the centralized error handler. What must it do, in what order, and what must it never do?

Order matters. (1) if (res.headersSent) return next(err) — the response is already streaming, so the status can't change; delegating to Express's default destroys the connection instead of emitting a corrupt body. (2) Classifyerr instanceof AppError (operational) vs everything else (programming); derive status from the error, defaulting to 500.

(3) Log exactly once, here, with the full context the funnel uniquely has: error + stack, traceId (3.8.7), route, method, user id, status — at warn for 4xx and error for 5xx so alerting can key on severity.

(4) Emit one envelope (9.6.1): stable code, status, human title, field-level errors for validation, and the traceId. (5) Never leak on 5xx: no stack, no SQL, no file paths, no library versions, no upstream response bodies — the client gets INTERNAL_ERROR + trace ID; the log gets everything (Part 8.5's information disclosure).

Never do: respond twice (guard with headersSent); log-and-rethrow at intermediate layers (duplicate entries, lost context — log at the funnel); construct HTTP responses inside services (domain code throws domain errors; the funnel translates — 9.3.9's DIP for failures); or let an unhandled non-AppError be reported to the client as anything but opaque. Pair it with a 404 handler just above it so unmatched routes also produce the envelope.

StaffYour service's clients complain they cannot programmatically distinguish failures: some errors arrive as { error: string }, some as { message, code }, some as HTML, and 500s occasionally include SQL fragments. Design the remediation program for a 60-endpoint Express API with active consumers.

Diagnosis: the error contract has no home — each handler invented a shape (9.1's duplicated-knowledge failure applied to failure itself), unmatched routes fall through to Express's HTML default, and 5xx responses forward raw driver messages (an information-disclosure vulnerability, not just an how pleasant it is to use problem — SQL fragments reveal schema and library versions, Part 8.5).

Program, in shippable order: (1) Define the envelope and the code registry — a Problem-Details-shaped response (9.6.1) plus a catalog of stable code values owned like an API surface (added additively, never renamed — 9.6.3's permanence law); publish it in the OpenAPI spec as a shared component so it's generated into clients (9.6.4). (2)

Install the funnel — typed AppError hierarchy, centralized handler, 404 handler, async wrapper — and, crucially, do not change any handler yet: the funnel's first version normalizes whatever legacy handlers throw or send, so every response immediately gains the envelope for uncaught paths and the leak stops today. (3)

Migrate handlers inward — replace res.status(400).json({error}) with throw new ValidationError(...), hotspot-first (9.1); a lint rule bans res.status(4|5xx) outside the funnel, so the count of violations is the burn-down metric. (4)

Protect the contract in CI — contract tests asserting that every documented error code is producible and every produced code is documented; a response-shape assertion in the integration suite; and a redaction test that fires the driver's error and asserts no SQL reaches the body. (5)

Communicate the change as additive — the envelope adds fields, so tolerant clients keep working; announce the code registry, give consumers a migration window to branch on code instead of message strings, and monitor per-consumer usage of the legacy shapes (9.6.3's deprecation practice).

The org lesson to record: error responses are API surface with the same permanence as success responses — they belong in the spec, the review checklist, and the test suite, not in whatever each handler improvised under deadline.

Flashcards

FlashFour-argument rule

Express detects error handlers by fn.length === 4. (err, req, res) is arity 3 → silently a normal middleware. Register last, after routes + 404.

FlashOperational vs programming

Operational = expected (401/404/409/422/429/504) → respond, no page. Programming = bug → 500 opaque + full logs + process suspect. Typed AppError makes it mechanical.

FlashAsync trap + fixes

Express 4 ignores returned promises → rejection = hung request. Fix: try/catch, asyncHandler wrapper, express-async-errors, or Express 5 (native). Enforce ONE.

FlashCentralized handler order

headersSent → delegate · classify · log once with traceId/route/user · one envelope with stable code · never leak stacks/SQL on 5xx.

FlashValidation rules

Validate body+params+query+headers; REPLACE raw with parsed; strip unknown keys (mass assignment); field-level errors for the UI.

FlashZod vs Joi vs express-validator

Zod: types + OpenAPI from one schema (TS default). Joi: expressive cross-field, separate types. express-validator: chains + sanitizers. Validation rejects; sanitization transforms; XSS = output encoding.

Scenario Drill

DrillBuild the complete error-and-validation architecture for a payments-adjacent Express API: idempotent charge creation, strict input rules, PCI-adjacent logging constraints (never log card data), partner-facing error codes, and an SRE requirement that 4xx never pages while 5xx always does. Specify types, middleware, the handler, and the three tests that prove it.

Types first — the classification is the design. AppError base (status, stable code, isOperational, optional details), with a payments-specific tier: ValidationError (422), IdempotencyKeyReusedError (422, IDEMPOTENCY_KEY_REUSED9.6.3), RequestInFlightError (409 + retryAfter), CardDeclinedError (402, code: CARD_DECLINED, carrying the network's decline reason mapped to our stable vocabulary — never the PSP's raw string, which changes without notice and may embed PAN fragments), UpstreamTimeoutError (504), ConflictError (409). Domain services throw these; no service constructs an HTTP response (9.3.9's DIP for failure).

Validation middleware — Zod schemas per route covering body, params, query, and the Idempotency-Key header (required, UUID-shaped, 400 IDEMPOTENCY_KEY_REQUIRED when absent — the write-safety contract enforced at the edge, 9.6.3); amount as integer minor units with .int().positive() (never a float — 9.7.29's money law); unknown keys stripped; parsed values replacing raw. Schemas double as the OpenAPI source so partner docs and the runtime check cannot drift (9.6.4).

The PCI constraint shapes the logger, not the handler's shape: a redaction layer in the logging pipeline (3.8.7) with an allow-list serializer — request bodies are never logged wholesale; only enumerated safe fields (amount, currency, orderId, last4) plus the traceId; PAN/CVV/track data are structurally unreachable because the parsed request object handed to the logger is a projection, not the raw body. This is the same "don't leak" rule as section 3's 5xx bodies, applied to the log sink — and it's tested (below), because a redaction rule nobody tests is a redaction rule that regressed last quarter.

Centralized handler per section 3, with two payment-specific behaviors: 402 CARD_DECLINED carries the mapped decline code in details (partners branch on it — it's contract, so it enters the code registry), and any non-AppError produces 500 INTERNAL_ERROR with only a traceId.

SRE requirement, implemented in the funnel: log level is derived from status — 4xx → warn, 5xx → error — and alerting keys strictly on error level plus a rate threshold, so a partner hammering invalid requests floods dashboards but pages nobody, while one genuine 500 pages immediately. Add a rate-based exception: a 4xx spike above baseline raises a low-severity ticket (mass 422s usually mean we shipped a breaking validation change — 9.6.3's additive law violated).

The three proving tests: (1) envelope conformance — a parameterized integration test firing one request per error class and asserting status, stable code, envelope shape, and presence of traceId (this is also the contract test backing the published spec); (2)

the leak test — force a driver-level failure (bad SQL, PSP 500) and assert the response body contains no stack frame, no SQL keyword, no upstream payload, and that the log contains all of them plus the traceId (proving the funnel splits audiences correctly); (3)

the redaction test — post a request containing a synthetic PAN in an unexpected field and assert it appears nowhere in captured log output, at any level (the constraint that would otherwise silently rot). Closing frame for the design doc: errors are a typed domain concept translated once at the boundary; validation is the same translation in the inbound direction; and both are enforced by tests because both are promises to people outside this codebase.