Posts
Decoupling UI Copy From API Error Contracts
Users need human-readable error messages. Software needs stable, machine-readable error identifiers. Neither should have to parse the other. That is the boundary I want when building error handling for a web application. …
In this article
Users need human-readable error messages. Software needs stable, machine-readable error identifiers.
Neither should have to parse the other.
That is the boundary I want when building error handling for a web application. The backend should explain what failed in a predictable structure. The presentation layer should decide how that failure is presented to the person trying to finish a task.
In a smaller application, that presentation layer may be the browser. In an enterprise application, it may include a backend for frontend (BFF), a shared message catalog, or a localization service. The deployment diagram can change without changing the boundary.
It sounds like a small distinction. It is also the difference between an error-handling system you can maintain and a collection of string comparisons waiting to betray you on a Friday afternoon.
Error Responses Have Two Audiences
An API error response serves at least two audiences:
- Software needs stable semantics so it can choose the correct behavior.
- A person needs clear language explaining what happened and what to do next.
Those audiences do not need the same thing.
A frontend might need to know that a postal code is invalid so it can highlight the postal-code field. The person using the form needs something more useful than INVALID_POSTAL_CODE—and probably more specific than “Unprocessable Entity.”
The mistake is not using an application error code. A stable code is a useful part of the API contract. The mistake is treating that code as UI copy, or treating a human-readable backend message as a stable value the frontend can parse.
HTTP status -> broad class of failure
Application code -> specific condition software can handle
Structured data -> context needed to recover
UI message -> language written for this user and this screen
Each layer has one job. That makes the boundary easier to reason about.
HTTP Status Codes Are Necessary, but Not Sufficient
HTTP status codes matter. Clients, proxies, monitoring tools, and developers all understand the difference between a 401, 404, and 500.
They are still too broad to drive most UI messaging by themselves.
Consider a 422 Unprocessable Content response from a checkout form. It could mean:
- the postal code is invalid for the selected country;
- the requested quantity is no longer available;
- the promotional code expired;
- several fields failed validation at once.
One status code cannot tell the frontend which control to highlight or which recovery action to offer. This is why RFC 9457 Problem Details exists: HTTP status codes cannot always carry enough information about a particular failure.
Status codes should describe the broad HTTP outcome. They should not be forced to carry all of the application’s business semantics.
A Useful JSON:API Error Contract
If an API already follows JSON:API, its error object gives us a well-defined structure instead of another handcrafted envelope.
Here is a validation response for an invalid postal code:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/vnd.api+json
{
"errors": [
{
"id": "err_01JABC123",
"status": "422",
"code": "INVALID_POSTAL_CODE",
"title": "Invalid postal code",
"detail": "The postal code is not valid for the selected country.",
"source": {
"pointer": "/data/attributes/postalCode"
},
"meta": {
"country": "US"
}
}
],
"meta": {
"requestId": "req_01JABC100"
}
}
There is a reason for each member:
statusdescribes the HTTP result associated with this error. JSON:API represents it as a string.codeidentifies the application-specific condition in a form software can safely compare.source.pointerpoints to the value in the request document that caused the failure.titlesummarizes the general problem.detaildescribes this particular occurrence in human-readable language.metacarries structured, non-standard context without cramming data into a sentence.idand the request identifier give support and operations teams something concrete to investigate.
JSON:API allows multiple error objects in the top-level errors array. That is especially useful for forms because one response can describe several invalid fields without inventing a new response shape for every endpoint.
It also keeps data and errors separate. A JSON:API document must not contain both at the top level. Success data is success data; failure information is failure information. I appreciate a boundary with enough conviction to say no.
Decouple at the Presentation Boundary
The presentation boundary should translate the wire-level error into a smaller domain-level error before a component sees it.
JSON:API response
|
v
API adapter / normalizer
|
v
Frontend domain error
|
v
Localized UI copy and recovery behavior
The component should not need to know that one backend calls a condition INVALID_POSTAL_CODE while a third-party address service calls it POSTCODE_FORMAT_ERROR. Both adapters can normalize those values into the same frontend concept.
Here is a deliberately small TypeScript example:
type JsonApiError = {
id?: string;
status?: string;
code?: string;
detail?: string;
source?: {
pointer?: string;
};
meta?: Record<string, unknown>;
};
type UiError = {
field?: "postalCode";
messageKey: "checkout.invalidPostalCode" | "errors.unexpected";
values?: Record<string, string>;
retryable: boolean;
supportId?: string;
};
function fieldFromPointer(pointer?: string): UiError["field"] {
if (pointer === "/data/attributes/postalCode") {
return "postalCode";
}
return undefined;
}
function normalizeApiError(error: JsonApiError): UiError {
const field = fieldFromPointer(error.source?.pointer);
if (error.code === "INVALID_POSTAL_CODE") {
const country =
typeof error.meta?.country === "string"
? error.meta.country
: "the selected country";
return {
...(field ? { field } : {}),
messageKey: "checkout.invalidPostalCode",
values: { country },
retryable: false,
...(error.id ? { supportId: error.id } : {}),
};
}
return {
messageKey: "errors.unexpected",
retryable: error.status?.startsWith("5") ?? false,
...(error.id ? { supportId: error.id } : {}),
};
}
Put i18n and l10n at the Presentation Boundary
The UI can pass messageKey and values to its localization library:
const message = translate(uiError.messageKey, uiError.values);
The English copy might become:
Enter a valid postal code for US.
Another locale can express the same idea naturally. Another screen can use different wording for the same domain condition.
This is where internationalization (i18n) and localization (l10n) belong in the design. The application prepares for multiple languages by looking up a stable message key and passing structured values. A locale-specific catalog supplies the translated sentence, plural rules, number formats, and other regional details.
That catalog does not have to live inside the frontend bundle. Depending on the application, the lookup might happen in:
- a localization library shipped with the frontend;
- a shared package used by several micro-frontends;
- a BFF that already knows the user’s locale;
- a translation or content service managed across several products.
The important part is not which process performs the lookup. The important part is that INVALID_POSTAL_CODE and { "country": "US" } remain stable inputs while the final sentence comes from the appropriate locale catalog.
I generally avoid making an API return a frontend-specific key such as checkout.invalidPostalCode. That merely moves UI coupling into the response. The adapter can map the API’s domain code to the message key used by that particular presentation layer.
An API may localize title or detail when it serves human-facing consumers and honors a locale such as Accept-Language. That can be useful, but the client still should not treat the translated sentence as a stable identifier. In a multi-channel system, a mobile application, support dashboard, and checkout screen may all need different copy for the same error.
The backend does not need a deployment because someone improved a sentence, and the checkout screen does not need to adopt the vocabulary of an internal service.
That is useful decoupling.
Do Not Make Human-Readable Text Your Protocol
Both JSON:API and RFC 9457 support human-readable error details, and those details may be localized. That does not make the sentences safe program inputs or the default source of UI copy.
Avoid logic like this:
if (error.detail === "Postal code is invalid.") {
// Highlight the postal-code field.
}
Punctuation changes. Copy gets clarified. Responses get localized. A vendor changes “postal code” to “ZIP code,” and suddenly the application has forgotten how forms work.
Use code, an error-type URI, source, and structured metadata for program behavior. Treat title and detail as diagnostic or carefully controlled human-readable information—not as an undocumented protocol hiding inside a sentence.
The frontend also should not blindly display every backend or third-party message. Unexpected errors can expose stack traces, database details, service names, or information useful for account enumeration. OWASP’s error-handling guidance recommends returning generic responses for unexpected failures while logging the useful technical details on the server.
For an unknown condition, the UI needs a safe fallback:
We could not save your changes. Try again. If the problem continues, contact support with request ID
req_01JABC100.
The user gets a next step. Support gets a receipt. Nobody gets a stack trace as a party favor.
Third-Party Errors Belong Behind an Adapter
Third-party APIs will not share your error vocabulary. That is normal. Do not spread their codes throughout components to compensate.
Put the translation at the integration boundary:
Address service POSTCODE_FORMAT_ERROR --+
|
Orders API INVALID_POSTAL_CODE ---------+-> invalidPostalCode
|
Legacy API error 100723 ----------------+
Your frontend domain stays stable even when a provider changes. Only the affected adapter needs to understand that provider’s contract.
This is also where you decide what cannot be normalized. An authentication failure, a network timeout, and invalid input should not all become somethingWentWrong. They have different security constraints, recovery actions, and places in the interface.
The Message Still Has to Work for People
A technically perfect error object can still produce a miserable interface.
When presenting errors:
- Identify the problem in text, not with color alone.
- Put field errors next to the affected control when possible.
- Associate the message with the input using the appropriate HTML and ARIA attributes.
- Move focus to, or announce, an error summary after a failed submission when the change would otherwise be missed.
- Preserve the person’s input.
- Explain how to correct the problem when doing so is safe.
- Offer retry, sign-in, navigation, or support actions when those actions can actually help.
The W3C guidance for identifying input errors requires detected errors to be identified and described in text. Good error handling is not finished when the JSON parses. It is finished when the person can understand the problem and continue.
JSON:API or Problem Details?
Use the contract that fits the rest of the API.
If the API already uses JSON:API, use its error objects. They support multiple errors, application codes, source pointers, links, and metadata without adding a second error format. I cover the broader response contract in Building Better REST APIs.
If the API does not use JSON:API, adopting the entire specification only for error handling is probably more machinery than the problem needs. RFC 9457’s application/problem+json format is a lighter option designed specifically for HTTP problems.
And if you only borrow the appearance of JSON:API without following its media type and document rules, call the response JSON:API-inspired. Standards are useful because words mean things. We should let them.
The Rules I Keep
When I design this boundary, I want the following to be true:
- HTTP status codes describe broad HTTP semantics.
- Stable codes or problem types drive program behavior.
- Structured fields carry context; prose does not secretly carry data.
- An adapter converts API errors into frontend domain errors.
- The presentation layer resolves localized, contextual copy and recovery actions.
- Unknown errors have a safe fallback and a support identifier.
- Error messages are accessible and help the person move forward.
The goal is not to eliminate every dependency between the frontend and backend. They are collaborating systems. A documented error contract is healthy coupling.
The goal is to put that coupling in one deliberate place, keep human language out of machine logic, and make failures useful to the person who actually has to deal with them.