Unpopular opinion: you shouldn't send error messages from your API to your UI.
Instead you should be sending error codes
/**
* This could quite easily be something generated by the backend and
* consumed by the frontend.
*
* It doesn't have to have these types of values, but it's a nice way to
* guide your consuming developers to more in depth documentation about
* the error.
*/
export const MyApiErrorCode = {
NETWORK_ERROR: 'https://myapi.com/errors/NETWORK_ERROR',
USER_NOT_FOUND: 'https://myapi.com/errors/USER_NOT_FOUND',
USER_NOT_AUTHORIZED: 'https://myapi.com/errors/USER_NOT_AUTHORIZED',
} as const;
import type { MyApiErrorCode } from './errors';
export type MyApiBaseResponsePayload<T> = {
/* Programatic business logic error code */
code: keyof typeof ApiErrorCode;
/* A message to consuming developers which should include a url to more information. */
message: typeof ApiErrorCode[keyof typeof ApiErrorCode];
/* any data */
data: T;
};
export type HttpResponse<T> = {
status: HTTPStatusCode;
data: MyApiBaseResponsePayload<T>;
...
}
Everyone does it. It's easy. It's fast. It's convenient. It's a bad idea. It doesn't scale.
Most teams that end up in these scenarios get there by way of several origin stories
- They had a monolith that pulled values out of the db and rendered html on the server.
- They made the backend-for-frontend api. An api that exists for the sole purpose of separating the backend from the frontend, different repo, directory...whatever.
- They simply don't have experience with building apis for disparate clients.
This is ok if it's only ever going to be a single app (but they rarely do).
If you know that you're going to have multiple apps, on multiple platforms, then you need to start thinking about how you're going to handle errors.
I've worked on many teams that all seem to make some excuse for why they're sending error messages from the api to the ui.
it's easier
Until, your design team who don't really know anything about the api, want to change the error message or the behaviour that results from the error message.
this endpoint is only used by one app
Until it's not... when you need to add another app with different user flows.
We'll just change the error message.
What if the error message needs to have links in it? It's good UX to help the user take a good action on the error. A mobile app has completely different mechanics about linking to other screens than a web app.
Lets add a couple of apps. On different platforms.
Does the api error message make sense in the context of a mobile app? What about a desktop app? What about a CLI app?
Or maybe you don't want to even show a message, but need perform some kind of logic branching based on the error.
Arguably, these error messages should be the domain of the UX and UI teams making the frontends.
Part of the challenge is understanding that your API is a product. It's a product that is consumed by other products.
Once you move yourself into that mindset, you can start to think about how you can make your API more robust and scalable.
Each endpoint, resolver, field etc. Serve to provide answers to focused questions, response to operations, or data to be consumed. Each certainly having requirements that needs to be met in order to be successful.
Some ideas I've pondered on:
-
Use a framework that generates your api documentation and produces artifacts for other projects to consume. It could be types, a schema, or even a client library. The error codes can be baked into the documentation and artifacts.
-
Return machine readble error codes in CONSTANT_CASE. perhaps you have a type system that allows for ensuring each code is unique across the codebase?
-
Next to each response code, have a link to a page that describes the error in detail. This page can be consumed by the documentation generator and used to produce the artifacts.
Which one looks more robust and scalable?
try {
const response = await fetch('/api/users');
const data = await response.json();
} catch (error) {
if (error.message === 'Failed to fetch') {
// show a message to the user
} else if (error.message === 'User not found') {
// show a message to the user
} else if (error.message === 'User not authorized') {
// show a message to the user
} else {
// show a message to the user
}
}
or
try {
const response = await fetch('/api/users');
const data = await response.json();
} catch (error) {
if (error.code === 'NETWORK_ERROR') {
// show a message to the user
} else if (error.code === 'USER_NOT_FOUND') {
// show a message to the user
} else if (error.code === 'USER_NOT_AUTHORIZED') {
// show a message to the user
} else {
// show a message to the user
}
}
language and grammar: Too long. Too vague. Has spelling or grammar mistakes.
These kinds of problems are hard to catch in code reviews, automated tests and manual testing; and when someone eventually notices it, it's a pain to fix.
You just know that there's some error.match(/not found/i)
somewhere in the frontend.
Sure, there's a certain amount that can be found with spellcheck tools, but consider the following:
- spelling mistakes in error messages, or
- spelling mistakes in variable names that end user never sees.
You'll either end up drowning in linting error fatigue or you'll waste time fussing about the spelling of a variable name.
Problems with tone: Too formal. Too casual. Too technical. Too friendly. Too impersonal.
Usually happens when there's no design language producd or socialised by the UX teams.
This artifact should put everyone on the same page as to how the user interface, the phone calls, etc should communicate to the customers.
Problems with context: Assumptions about platform lead to mechanics that are confusing on other platforms.
This is a big one. It's easy to assume that the error message will be displayed in a web browser. But what if it's a mobile app? What if it's a CLI app? What if it's a desktop app?
React Native doesn't use <a href="/some/page.html">
to link to other screens. It often refers to other screens by name using a custom React component.
So in summary, user facing error messages are part of the user experience and as such, are each unique individual problems to solve.
- It's a UX and design problem to solve, not an engineering problem.
- Lean on the error codes. They're the domain of the engineering team.
- Each point in a users journey will likely benefit from different ways to explain the same kind of error code.
function MyComponentDataLayer({ thingId }: { thingId?: string }) {
const getThingDetailQuery = useGetThingDetailQuery({
thingId,
skip: !thingId,
});
/** don't bother rendering if we haven't started the query */
if (!getThingDetailQuery.intiated) {
return null;
}
if (getThingDetailQuery.loading) {
return <Loading label="Loading thing..." />;
}
/**
* we want to capture this specific error state and provide a
* unique UI experience.
*/
if (getThingDetailQuery.code === MyApiErrorCode.THING_NOT_FOUND) {
return (
<EmptyState>
<Stack gap="medium">
{/**
* This text has been approved by the UX research team.
* http://figma.com/jklngsdfi78?page=123
*/}
<p>Sorry, we couldn't find the thing you were looking for.</p>
<p>Maybe it was deleted?</p>
<Button
onClick={onCreateNewThingClick}
label="Create a new thing"
/>
</Stack>
</EmptyState>
);
}
/**
* Other errors, we'll just show a generic error state.
*/
if (getThingDetailQuery.code !== MyApiErrorCode.THING_FOUND) {
return (
<EmptyState image={ErrorIllustrationMedium}>
<ErrorState code={getThingDetailQuery.code} />
</EmptyState>
);
}
return <ThingDetail thing={getThingDetailQuery.data} />;
}