
From Contract Boundary to Error Boundary: Structuring API Error Handling in a TypeScript Frontend
In a previous post, I covered why TypeScript types alone can't protect you from a backend that returns something you didn't expect, and how to build a small boundary that validates both the outgoing request and the incoming response against Zod-style schemas before your application ever touches the data. That post answered one question: Is this data actually shaped the way I think it is? It left another question open: When the answer is no, or when the request fails for a completely different reason (like a timeout or a dropped connection), what does the rest of the app do with that failure? In practice, "the rest of the app" usually does something different depending on who's writing it: One component checks directly. Another checks . A form manually digs through the error to find...
In a previous post, I covered why TypeScript types alone can't protect you from a backend that returns something you didn't expect, and how to build a small boundary that validates both the outgoing request and the incoming response against Zod-style schemas before your application ever touches the data. That post answered one question: Is this data actually shaped the way I think it is? It left another question open: When the answer is no, or when the request fails for a completely different reason (like a timeout or a dropped connection), what does the rest of the app do with that failure? In practice, "the rest of the app" usually does something different depending on who's writing it: One component checks directly. Another checks . A form manually digs through the error to find field-level messages. A toast just displays whatever string happens to be on . The app works, but every layer speaks a different error dialect. This post is Part 2: it takes the validation boundary from Part 1 and builds the missing piece on top of it, a single, normalized shape that every layer of the app can speak, plus the logging, messaging, and form-mapping that make it actually usable. Quick Recap: The Validation Boundary From Part 1, the wrapper validates request payloads and response bodies against schemas, and throws one of two typed errors when something doesn't match the contract: means the frontend built a bad request. means the backend returned something that doesn't match its own contract. Every call site declares its schemas up front: That's the whole boundary. Full details are in Part 1. What it doesn't cover is what happens after one of these errors is thrown, or after Axios itself fails for a reason that has nothing to do with schemas (a timeout, a cancelled request, a 500 from the server). That's where this post picks up. The Core Idea: One Error Shape, One Boundary The fix is to stop letting UI components see raw Axios errors, raw validation errors, or raw exceptions at all. Failures are translated into a common error model before reaching UI consumers: transport failures and HTTP error responses are normalized by the Axios interceptor, while the contract validation errors from Part 1 are converted into the same shape by explicitly calling at the call site (since they're thrown directly by , not by Axios, so the interceptor never sees them). Either path lands on the same predictable type: No matter whether the failure was a 500 from the server, a timeout, a cancelled request, or a response that didn't match the schema from Part 1, it comes out the other side as an . Components, forms, and toasts only ever need to understand this one shape. Step 1: Normalizing Everything at the Axios Interceptor Schema-validation failures are one category. Network errors, timeouts, and HTTP error responses are another, and they come from Axios itself. Rather than handling these ad hoc in every block, a single response interceptor converts all of them into the same : The flow becomes: Inside , each failure mode is identified and classified before being converted: Notice the early check: if something upstream already normalized the error (for example if a hook wraps and re-throws), we don't re-process it. This makes idempotent, which matters once you have interceptors, hooks, and query libraries (React Query, SWR) all potentially touching the same error object. Step 2: Error Responses Are External Data Too, Validate Them Here's the part that's easy to skip: we usually validate successful API responses, but the error body coming back from the backend is just as much untrusted external data. The contract for that envelope is just another Zod schema: A couple of details here are deliberate. is rather than optional: an item that isn't tied to a specific input (a general "this operation isn't allowed" error) still has to explicitly say , rather than silently omitting the key. And is validated as a real UUID, not just any string, since it's what ties a user-facing error back to a specific log entry on the backend. This pairs with a matching schema for successful responses, so both sides of every API call follow the same envelope shape: vs is a discriminant: given a raw response, you can tell which shape you're looking at before you've even touched the rest of the payload. So a well-formed error envelope from the backend looks like this: Or, because of a proxy, a gateway timeout, or a misconfigured endpoint, it might return something completely different: Both are "errors" from Axios's point of view, but only one of them matches the contract and is safe to trust. So before building an from the response body, it gets parsed against the schema: If the shape doesn't match, we don't try to guess at or . We fall back to a generic message and flag it internally as . This is the difference between "the backend told us the email is taken" (trustworthy, safe to show verbatim) and "something came back that we don't understand" (never shown verbatim to a user). Step 3: Keeping Technical Detail Away From Users and are useful to a developer reading logs. They mean nothing to a user, and showing them erodes trust in the product. So there's a translation layer between and what actually renders: The rule here is deliberate: Only errors that carry a (meaning they came from our own validated backend envelope, not from an unknown or malformed source) are allowed to expose their message directly. Everything else (network failures, malformed responses, unhandled exceptions) gets a generic fallback. This closes off an entire class of "leaking implementation details to the UI" bugs. On top of that, specific backend error codes get mapped to friendly, localized copy: This is also a natural place to hang i18n: swap the dictionary per locale and every error message in the app updates without touching a single component. Step 4: Mapping Backend Validation Errors Into Forms This is where the structured model really pays off. A backend field error shouldn't turn into a generic toast; it should land right next to the input that caused it: Two details matter here: decouples backend field names from frontend field names. If the backend calls it but the form calls it , you map it once: . Forms don't need to know about backend naming conventions. acts as an allowlist. If the backend returns an error for a field the form doesn't render, it's silently dropped instead of throwing or getting attached to a non-existent input. Usage in a component ends up almost boring, which is the point: The component doesn't know or care whether the failure was a validation error, a conflict, or a network issue. It just calls one function and gets field-level errors plus a safe display message. Step 5: Sanitizing Before You Log Anything Debugging needs logs, but logs are also a common place for secrets to leak. Every error report goes through sanitization before it touches : A few things worth calling out: Tokens () get masked with a regex targeting the header format specifically. Key-value patterns covering passwords, tokens, cookies, and OTPs are redacted regardless of casing or which object they came from. This catches things accidentally serialized into an error message, not just structured fields. Emails and phone numbers are stripped with pattern matching, since these often show up embedded in validation messages ("email [email protected] is already taken"). A hard length cap () prevents a single runaway error message (say, a giant stack trace or a reflected payload) from flooding the console or a log aggregator. The endpoint itself is also stripped of query strings before logging, since query params frequently carry tokens or PII: And the whole reporting function is guarded so that a bug in logging can never break the actual error-handling flow, and only runs in development: In production this becomes a no-op by default. Swap it for a real telemetry sink (Sentry, Datadog, your own endpoint) behind the same interface, and eve