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 apiRequest 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 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. Quick Recap: The Validation Boundary 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: export class ApiRequestValidationError extends Error { constructor(public readonly url: string, public override readonly cause: unknown) { super(`API request input does not match the contract for ${url}.`); this.name = "ApiRequestValidationError"; } } export class ApiResponseValidationError extends Error { constructor(public readonly url: string, public override readonly cause: unknown) { super(`API response does not match the contract for ${url}.`); this.name = "ApiResponseValidationError"; } } 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: const project = await apiRequest({ client: apiClient, method: "GET", url: `/projects/${id}`, responseSchema: projectSchema, }); 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 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: export type ApiSubError = { field?: string; message: string; code?: string; }; export type ApiError = { status: number | null; message: string; errors: ApiSubError[]; fieldErrors: Record; traceId: string | null; code: string | null; }; 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. 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 catch block, a single response interceptor converts all of them into the same ApiError: export function setupErrorInterceptor(apiClient: AxiosInstance) { apiClient.interceptors.response.use( (response) => response, (error) => Promise.reject(normalizeApiError(error)), ); } The flow becomes: Inside normalizeApiError, each failure mode is identified and classified before being converted: export function normalizeApiError(error: unknown): ApiError { if (isApiError(error)) return error; // already normalized upstream if (axios.isAxiosError(error)) { const status = error.response?.status ?? null; const endpoint = error.config?.url ?? null; const method = error.config?.method ?? null; const code = error.code ?? "API_ERROR"; if (axios.isCancel(error) || error.code === "ERR_CANCELED") { reportClientError({ kind: "api_request_cancelled", endpoint, method, status, code, message: error.message }); } else if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") { reportClientError({ kind: "api_timeout", endpoint, method, status, code, message: error.message }); } else if (!error.response) { reportClientError({ kind: "api_network_error", endpoint, method, status, code, message: error.message }); } // ... validate and unwrap error.response.data (see next section) } if (error instanceof Error) { const isValidationError = error instanceof ApiRequestValidationError || error instanceof ApiResponseValidationError; if (!isValidationError) { reportClientError({ kind: "unknown_client_error", code: "UNKNOWN_ERROR", message: error.message }); } return createApiError({ status: null, message: error.message, code: "UNKNOWN_ERROR" }); } // Non-Error throws (string, undefined, etc.) still get a safe fallback reportClientError({ kind: "unknown_client_error", code: "UNKNOWN_ERROR", message: "A non-Error value was thrown." }); return createApiError({ status: null, message: "Something went wrong", code: "UNKNOWN_ERROR" }); } 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. 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: export const apiErrorItemSchema = z.object({ code: z.string(), field: z.string().nullable(), message: z.string(), }); export const apiErrorResponseSchema = z.object({ success: z.literal(false), message: z.string(), errors: z.array(apiErrorItemSchema), traceId: z.string().uuid(), }); 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: export function successResponseSchema(dataSchema: T) { return z.object({ success: z.literal(true), message: z.string(), data: dataSchema, }); } 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. So a well-formed error envelope from the backend looks like this: { "success": false, "message": "Validation failed", "errors": [ { "code": "EMAIL_ALREADY_EXISTS", "field": "email", "message": "Email already exists" } ], "traceId": "9f3a2b91-4c1e-4a3d-9f2a-7e6b1d0c8f45" } Or, because of a proxy, a gateway timeout, or a misconfigured endpoint, it might return something completely different: { "error": "Internal failure" } 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: const envelopeResult = apiErrorResponseSchema.safeParse(error.response?.data); if (envelopeResult.success) { const envelope = envelopeResult.data; if (status !== null && status >= 500) { reportClientError({ kind: "api_server_error", endpoint, method, status, code, traceId: envelope.traceId, message: envelope.message, }); } return createApiError({ status, message: envelope.message, errors: envelope.errors, traceId: envelope.traceId, }); } // The body didn't match the expected contract, don't trust it if (error.response) { reportClientError({ kind: "malformed_api_error_response", endpoint, method, status, code, issues: getValidationIssues(envelopeResult.error), message: error.message, }); } return createApiError({ status, message: error.message || "Request failed.", code }); If the shape doesn't match, we don't try to guess at data.error or data.msg. We fall back to a generic message and flag it internally as malformed_api_error_response. 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 "Network Error" and "Request failed with status code 500" 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 ApiError and what actually renders: export function getApiErrorMessage(error: unknown) { if (!isApiError(error) || !error.traceId) { return fallbackErrorMessage; } const translatedMessage = error.code ? apiErrorMessages[error.code] : undefined; if (translatedMessage) { return translatedMessage; } return error.message; } The rule here is deliberate: Only errors that carry a traceId (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: const apiErrorMessages: Readonly = { PROJECT_NOT_FOUND: "This project no longer exists or you don't have access to it.", PROJECT_REVISION_CONFLICT: "This project has changed. We've loaded the latest version, please review again.", EMAIL_ALREADY_EXISTS: "An account with this email already exists.", // ... }; 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: export function applyApiFormErrors( error: unknown, setError: UseFormSetError, options: { fieldMap?: Partial; formFields: readonly Path[] }, ) { if (!isApiError(error)) { return { apiError: null, fieldErrors: {}, message: fallbackErrorMessage }; } const fieldMap = options.fieldMap ?? {}; const formFields = new Set(options.formFields); const mappedFieldErrors: Record = {}; for (const [field, message] of Object.entries(error.fieldErrors)) { const hasExplicitMapping = Object.prototype.hasOwnProperty.call(fieldMap, field); const mappedField = hasExplicitMapping ? fieldMap[field] : (field as Path); if (!mappedField || !formFields.has(mappedField)) continue; mappedFieldErrors[mappedField] = message; setError(mappedField, { type: "server", message }); } return { apiError: error, fieldErrors: mappedFieldErrors, message: getApiErrorMessage(error) }; } Two details matter here: fieldMap decouples backend field names from frontend field names. If the backend calls it identifier but the form calls it email, you map it once: fieldMap: { identifier: "email" }. Forms don't need to know about backend naming conventions. formFields 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: try { await apiRequest({ /* ... create account ... */ }); } catch (error) { const apiError = normalizeApiError(error); const { message } = applyApiFormErrors(apiError, setError, { formFields: ["email", "password", "confirmPassword"], fieldMap: { identifier: "email" }, }); toast.error(message); } 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 console.error: function sanitizeText(value: string) { return value .replace(/\bBearer\s+\S+/gi, "Bearer [REDACTED]") .replace( /\b(password|newPassword|confirmPassword|accessToken|refreshToken|token|cookie|authorization|csrfToken|otp|authenticationCode)\s*[:=]\s*\S+/gi, "$1=[REDACTED]", ) .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED]") .replace(/\+?\d[\d\s()-]{8,}\d/g, "[REDACTED]") .slice(0, 500); } A few things worth calling out: Tokens (Bearer xyz...) get masked with a regex targeting the Authorization 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 test@example.com is already taken"). A hard length cap (.slice(0, 500)) 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: function sanitizeEndpoint(endpoint: string | null | undefined) { if (!endpoint) return null; return sanitizeText(endpoint.split(/[?#]/, 1)[0] ?? ""); } 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: export function reportClientError(report: ClientErrorReport) { if (process.env.NODE_ENV !== "development") return; const safeReport = { kind: report.kind, endpoint: sanitizeEndpoint(report.endpoint), method: report.method?.toUpperCase() ?? null, status: report.status ?? null, code: report.code ? sanitizeText(report.code) : null, traceId: report.traceId ? sanitizeText(report.traceId) : null, message: report.message ? sanitizeText(report.message) : null, // ...issues, sanitized the same way }; try { if (warningKinds.has(report.kind)) { console.warn("[api-client]", safeReport); return; } console.error("[api-client]", safeReport); } catch { // Logging must never affect the application's actual error flow. } } 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 every call site in the app is already wired up correctly. Putting It All Together: A Signup Form Here's how the validation boundary from Part 1 and the five pieces above compose in a realistic flow: a signup form that can fail from bad input, a duplicate email, or a dropped connection. function useSignup() { const { setError } = useFormContext(); return async (data: SignupForm) => { try { const result = await apiRequest({ client: apiClient, method: "POST", url: "/auth/signup", data, requestSchema: signupRequestSchema, responseSchema: signupResponseSchema, }); return { success: true, result }; } catch (error) { const apiError = normalizeApiError(error); const { message } = applyApiFormErrors(apiError, setError, { formFields: ["email", "password", "confirmPassword"], }); toast.error(message); return { success: false }; } }; } Trace what happens for three different failures: User submits an invalid email format → caught client-side by requestSchema, thrown as ApiRequestValidationError, normalized to a generic ApiError with no traceId → form gets no field errors (it's a client bug, not a backend rejection) → toast shows the fallback message. In development, reportClientError logs the exact Zod issue paths so you can fix the schema mismatch immediately. Backend rejects because the email is taken → Axios error with a 409 and a valid error envelope → normalizeApiError parses the envelope, builds an ApiError with traceId and fieldErrors: { email: "..." } → applyApiFormErrors calls setError("email", ...) → the message under the email field shows apiErrorMessages.EMAIL_ALREADY_EXISTS, not the raw backend string. The user's wifi drops mid-request → Axios error with no error.response → classified as api_network_error, logged for debugging → ApiError has traceId: null → getApiErrorMessage returns the safe fallback, since we don't trust an error with no trace as a "known" backend message. One function call per layer, and the component never has to know which of these three happened. Why This Is Worth the Extra Code None of this is complicated in isolation: a type, an interceptor, a couple of try/catch blocks, some regexes. The value is in consistency: every failure in the app, no matter where it originates, ends up as the same ApiError shape by the time it reaches a component. That means: New developers don't need to learn a different error-handling idiom for every failure mode scattered across the codebase; they learn one shape. Forms, toasts, and logging all consume the same normalized data, so adding a new UI surface for errors (a banner, a modal) doesn't require re-deriving error logic. Backend contract changes fail loudly and immediately (schema validation) instead of silently producing undefined three renders later. Sensitive data has exactly one choke point to sanitize, instead of being a concern every developer has to remember at every console.log. Errors are not an edge case you handle once and forget. In any application that talks to a real backend, they're a first-class part of the architecture, and treating them that way, with the same rigor you'd apply to success responses, pays off the moment the app grows past "one component, one try/catch." Together with the validation boundary from Part 1, this gives the frontend two matching guarantees: data crossing into the app is verified before anything touches it, and data crossing into the UI as a failure is normalized before anything renders it. Runtime contracts on the way in, a structured error model on the way out.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to