The only way to manage errors in typescript
What’s wrong with this code ?
try {
// do something
} catch (error) {
// handle error here
}
The problem with this code is that in TypeScript, the caught error is not typed, and although it can be cast, it is not a safe practice since anything can be thrown, not only an error.
throw(1)
works for instance
- When we throw errors, it becomes difficult to determine what we are catching since
error
can be anything. - Additionally, it becomes challenging to identify which errors we need to handle, unless we search through all the function calls, which can be time-consuming and complex.
- If a new error is added and not handled, the code will still compile. It should not.
It is important to distinguish between unexpected errors, which should be thrown, and expected errors, such as business errors, which should not be thrown.
There are better ways to handle expected errors, which we will explore.
The outcome pattern
We are writing a method to allow users to purchase items in an e-commerce application. The method verifies that the item being purchased exists and is also available for purchase. There are four potential outcomes:
- The item is not ordered because this ID does not match an item
- The item is not ordered because it is out of stock.
- The item is not ordered because the selected color does not exist.
- The item is successfully ordered.
Thus, the method’s result can be expressed as a union of these 4 possible outcomes.
type OrderItemResult =
| {
outcome: "notOrdered";
reason: "itemNotFound";
}
| {
outcome: "notOrdered";
reason: "outOfStock";
}
| {
outcome: "notOrdered";
reason: "colorDoesNotExist";
availableColors: string[];
}
| {
outcome: "ordered";
};
const orderItem = async (itemId: string): Promise<OrderItemResult> => {
// ...
};
- The verb used in the method’s name is
order
, so the potential outcomes should be notOrdered or ordered. - Avoid using generic terms like success or error and instead use specific terminology. For example, instead of success, use ordered as it provides better clarity about what has succeeded.
- When necessary, add context to provide additional information that may be useful for debugging or displaying a clear error message in the app, as it’s done for
availableColors
.
The outcome pattern on steroids - switch guard
The switch guard is a robust structure that helps ensure that all potential cases are handled. Basically, It is a switch statement with a guard included in the default case. The guard is here to prevent the code from compiling if a case is left unhandled.
const throwUnhandledOutcome = (result: never) => {
throw new Error("Not handled case: " + JSON.stringify(result));
};
const someMethodInOurCode = async () => {
/* ... */
const result: OrderItemResult = await orderItem(itemId);
if (result.outcome === "notOrdered") {
switch (result.reason) {
case "itemNotFound":
return console.error("Item not found in catalog");
case "outOfStock":
return console.error("Item is out of stock");
case "colorDoesNotExist":
return console.error(
\`Selected color is not available. Available colors: \$\{result.availableColors.join(
", "
)}\`
);
default:
throwUnhandledOutcome(result);
}
}
/* here, handle the happy path */
/* ... */
};
The function throwUnhandledOutcome
has a parameter of type
never
. This means that during the build process, if TypeScript
detects a case that ultimately falls into the default handler, the
build will fail.
If we remove the outOfStock
case, we will receive the following
error message:
\`Argument of type '{ outcome: "notOrdered"; reason: "outOfStock"; }' is not assignable to parameter of type 'never'\`
If the result of the orderItem
method change, such as the
addition of a new outcome, Typescript will ask us to handle the new
outcome. If we don’t, the code will not build.
This is the best way to make sure business errors are properly handled everywhere in our code.