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 apiRequest boundary that validates both the outgoing request and the incoming response against Zod-style schemas before your application ever touches the data.
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 error.response?.status directly. Another checks error.code === "ECONNABORTED". A form manually digs through the error to find field-level messages. A toast just displays whatever string happens to be on error.message.
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 ApiError shape that every layer of the app can speak, plus the logging, messaging, and form-mapping that make it actually usable.
From Part 1, the apiRequest wrapper validates request payloads and response bodies against schemas, and throws one of two typed errors when something doesn't match the contract:
ApiRequestValidationError means the frontend built a bad request. ApiResponseValidationError 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 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 ApiError shape by explicitly calling normalizeApiError at the call site (since they're thrown directly by apiRequest, 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 ApiError. Components, forms, and toasts only ever need to understand this one shape.
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 catch block, a single response interceptor converts all of them into the same ApiError:
Inside normalizeApiError, each failure mode is identified and classified before being converted:
Notice the early isApiError(error) check: if something upstream already normalized the error (for example if a hook wraps apiRequest and re-throws), we don't re-process it. This makes normalizeApiError idempotent, which matters once you have interceptors, hooks, and query libraries (React Query, SWR) all potentially touching the same error object.
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. field is nullable() 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 field: null, rather than silently omitting the key. And traceId 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:
success: true vs success: false 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.
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 ApiError from the response body, it gets parsed against the schema:
