There are very few programs that work in complete isolation. Even if developers always write perfect code, there is a high likelihood of encountering errors when code interacts with external components like databases, REST APIs, and even that trendy npm package that has a bunch of stars!
Instead of sitting back while the server crashes, a responsible developer thinks defensively and prepares for when a malformed request comes in. In this article, we’ll cover a few of the most common approaches for error handling in TypeScript (TS). We’ll talk about most common error types, the shortcomings of null and try...catch
based error handling, and finally, propose a cleaner way to manage errors.
Let’s get started!
Note: If you’re not familiar with TypeScript, good news. The conditions that cause errors and the solutions also apply to JavaScript.
try...catch
Result
class
To effectively discuss and evaluate the different error handling approaches in TypeScript, it’s essential to first talk about the nature of errors.
Not all errors are created equal, and we can sort them into two main buckets: known errors and programmer errors.
Known errors are the errors that we know will happen (eventually): a DB connection fails, an HTTP call returns an error code, the user inputs an invalid email in a form… All these errors have in common that while developing, the developer is aware of them. When they happen, they are dealt with, and it’s possible to try to recover (for example, by retrying the HTTP call). Failing to deal with these errors correctly creates programmer errors (aka bugs).
Programmer errors (bugs) are uncaught known errors. If an array has a length of 10 and we try to access the element number 11, it will generate an exception which can bring the runtime down. Bugs are not known beforehand (or they would be dealt with and become known errors), so they can’t be planned for. Unhandled out-of-memory and other environment errors also are considered programmer errors, so a graceful recovery from them is often not possible.
It’s necessary to keep this distinction in mind since we will be addressing known errors in the remainder of the post. By definition, programmer errors are not known beforehand, so they shouldn’t be treated the same as known errors. We will expand on the dangers of mixing error types in the next section, so keep reading! 📚
Before we dig in, keep in mind that the following list is by no means exhaustive. The errors and solutions presented here are based on my subjective experience, so your mileage may vary. 🏎️
Returning null is a common way to indicate that something in your code went wrong. It is best used when a function only has one way to fail, however, some developers use it when a function has many errors.
Returning null in TypeScript forces null checks everywhere in your code, causing the specific information about the cause of the error to be lost. Returning null is an arbitrary representation of an error, so if you try returning 0
, -1
, or false
, you’ll end up with the same result.
In the code block below, we’ll write a function that retrieves data about the temperature and humidity of a given city. The getWeather
function interacts with two external APIs through two functions, externalTemperatureAPI
and externalHumidityAPI
, and aggregates the results:
const getWeather = async ( city: string ): Promise<{ temp: number; humidity: number } | null> => { const temp = await externalTemperatureAPI(city); if (!temp) { console.log(`Error fetching temperature for ${city}`); return null; } const humidity = await externalHumidityAPI(city); if (!humidity) { console.log(`Error fetching humidity for ${city}`); return null; } return { temp, humidity }; }; // ... const weather = await getWeather("Berlin"); if (weather === null) console.log("getWeather() failed");
We can see that on entering Berlin
, we receive the error messages Error fetching temperature for ${city}
and Error fetching humidity for ${city}
.
Both of our external API functions can fail, so getWeather
is forced to check for null for both functions. Although checking for null is better than not handling errors at all, it forces the caller to make some guesses. If a function is extended to support a new error, the caller won’t know it unless they check the inners of the function.
Let’s say that externalTemperatureAPI
initially throws a null when the temperature API returns HTTP code 500
, which indicates an internal server error. If we extend our function to check the structural and type validity of the API response (i.e., check that the response is of type number
), the caller will not know if the function returns null due to HTTP code 500
or an unexpected API response structure.
try...catch
Creating custom errors and throwing them is a better option than returning null because we can achieve error granularity, which enables a function to throw distinct errors and allows the caller of the function to handle the distinct errors separately.
However, the execution of any function that throws an error will be halted and the error propagated up, disrupting the regular flow of the code. Although this may not seem like a big deal, especially in small applications, as your code continues to layer try...catch
after try...catch
, readability and overall performance will decline.
Let’s try to solve the error in our weather example with the try...catch
method:
const getWeather = async ( city: string ): Promise<{ temp: number; humidity: number }> => { try { const temp = await externalTemperatureAPI(city); try { const humidity = await externalHumidityAPI(city); } catch (error) { console.log(`Error fetching humidity for ${city}`); return new Error(`Error fetching humidity for ${city}`); } return { temp, humidity }; } catch (error) { console.log(`Error fetching temperature for ${city}`); return new Error(`Error fetching temperature for ${city}`); } }; // ... try { const weather = await getWeather("Berlin"); } catch (error) { console.log("getWeather() failed"); }
In the code block above, when we try to access the externalTemperatureAPI
and the externalHumidityAPI
, we are met with two errors in the console.log
, which are then stopped and propagated up several times.
We have discussed the shortcomings of try...catch
regarding not knowing if a function may go through and thus polluting the codebase with unnecessary checks. That’s quite a bummer, but try...catch
has another spooky surprise for us. 🎃
Remember the distinction between known
and programmer errors
?
try...catch
catches ALL of the errors, mixing them up. This is dangerous since bugs get flagged as known errors
, triggering unexpected side effects and making debugging the original issue much harder:
try{ // as in the example above, `getWeather` will throw an exception when the weather = await getWeather('Berlin') // `programmer error`, the accessed property is not there -> TypeError is thrown const userId = weather.notHere.norHere // TypeError: Cannot read properties of undefined (reading 'norHere') }catch(error){ // at runtime, the TypeError from `weather.notHereNorHere` is caught here // TypeError is now mixed with the `known error` // `known error`, retry the request weather = await getWeather('Berlin') }
But don’t despair, let’s see how we can avoid mixing the two error types!
Result
classWhen you use either of the two error handling approaches discussed above, a simple mistake can add unnecessary complexity on top of the original error. The problems that arise when returning null and throwing a try...catch
are shared in other frontend languages like Kotlin, Rust, and C#. These three languages use the Result
class as a fairly generalized solution.
Regardless of whether execution succeeds or fails, the Result
class will encapsulate the results of the given function, allowing the function callers to handle errors as part of the normal execution flow instead of as an exception.
When paired with TypeScript, the Result
class provides type safety and detailed information about the possible errors that a function could result in. When we modify the error results of a function, the Result
class provides us with compile-time errors in the affected places of our codebase.
Let’s look back at our weather example. We’ll use a TypeScript implementation of Rust’s Result and Option objects, ts-results:
There are other packages for TypeScript with very similar APIs, like NeverThrow, so you should feel free to play around.
import { Ok, Err, Result } from "ts-results"; type Errors = "CANT_FETCH_TEMPERATURE" | "CANT_FETCH_HUMIDITY"; const getWeather = async ( city: string ): Promise<Result<{ temp: number; humidity: number }, Errors>> => { const temp = await externalTemperatureAPI(city); if (!temp) return Err("CANT_FETCH_TEMPERATURE"); const humidity = await externalHumidityAPI(city); if (!humidity) return Err("CANT_FETCH_HUMIDITY"); return Ok({ temp, humidity }); }; // ... const weatherResult = await getWeather("Berlin"); // weatherResult is fully typed if (weatherResult.err) console.log(`getWeather() failed: ${weatherResult.val}`); if (weatherResult.ok) console.log(`Weather is: ${JSON.stringify(weather.val)}`);
Adding type-safe results from the function and prioritizing error handling in our code is an improvement from our previous examples. However, we still have work to do. Let’s explore how we can make our type checks exhaustive.
It is important to note that favoring the Result
class doesn’t mean that you won’t use try...catch
structures. try...catch
structures are still required when you are working with external packages.
If you think the Result
class is worth following, you can try encapsulating those touchpoints in modules and using the Result
class internally.
When handing functions that can return multiple errors, it can be helpful to provide type checks for covering all error cases. Doing so ensures that the caller of the function can react dynamically to the type of error, and it provides certainty that no error case is overlooked.
We can achieve this with an exhaustive switch:
// Exhaustive switch helper class UnreachableCaseError extends Error { constructor(val: never) { super(`Unreachable case: ${val}`); } } // ... const weatherResult = getWeather("Berlin"); if (weatherResult.err) { // handle errors const errValue = weatherResult.val; switch (errValue) { case "CANT_FETCH_TEMPERATURE": console.error("getWeather() failed with: CANT_FETCH_TEMPERATURE"); break; case "CANT_FETCH_HUMIDITY": console.error("getWeather() failed with: CANT_FETCH_HUMIDITY"); break; default: throw new UnreachableCaseError(errValue); // runtime type check for catching all errors } }
Running our weather example with the exhaustive switch will provide compile-time errors under two sets of circumstances. One is when all error cases are not handled, and the other is when the errors in the original function change.
Now, you know an improved solution for handling common errors in TypeScript! Knowing how important error handling is, I hope you’ll use this method to get the most specific information about any errors in your application.
In this tutorial, we covered the downsides of some widespread approaches like returning null and the try...catch
method. Finally, we learned how to use the TypeScript Result class with an exhaustive switch for error catching.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
3 Replies to "Improving TypeScript error handling with exhaustive type checking"
There are 3 issues with what’s being proposed in this article:
1. Having too many try/catch blocks everywhere isn’t necessary, and is usually a code smell. Errors need to be caught usually only when we need to “wrap” an error (i.e. bubble up a different kind of error type or message) and once at the top level as a generic way to catch unhandled errors and alert the user in a friendlier way than crashing the app.
2. The “it catches all the errors without distinction” argument is only a problem if you’re not checking the error type in the catch block. If you have distinct types of each error, distinguishing between them is as simple as doing `if (error instanceof MyCustomErrorType)` to check for it and doing something, and just doing `else throw error` if it’s any other error to bubble it up.
3. The “Result” pattern is fine in other languages like Rust or Go where it’s a first-class citizen of the language, but it doesn’t make sense in TypeScript/JavaScript simply because “Result” types aren’t a native construct and starts feeling unnatural and verbose after a while, when just knowing how to throw and catch errors properly works just fine, is less verbose, and is tightly integrated into the language.
The `Result` pattern is definitely not the only way, and in small or simple apps try/catch may be enough.
In order to effectively check the error types, you have to manually go into all the functions that are called in a given try block (and all the nested functions). A project-wide error convention may mitigate this, but it doesn’t solve the issue of needing to perform manual checks. By using the pattern, all the returns of a function are typed, not only the happy path.
At the end of the day, this pattern makes the error flows explicit and typed, which is not supported by the built in try/catch. Verbosity is a subjective topic, there are plenty of libraries that expose a terser API: https://github.com/swan-io/boxed
Finally, this pattern is already in use by built in JS APIs, such as the Fetch API (using the Response class). The post adds extra ergonomics by leveraging TypeScript.
Some false examples in the demo code.
Rule: Only throw an exception if the method can not handle the error.
If the method can handle the error, great; else, allow the exception to naturally go up the chain until something can address the problem.
Rule: Never double-log an error/exception.
Double-logging is a common problem in event management where the same error is seen all through the logs. The only code that should be logging the problem is the code that deals with the exception.
Conclusion: Following the basic rules will clean up the code and the log files and eliminate the need for the Result class/data.