The Case For Returning Errors Instead of Throwing Them in Typescript
13 August 2022 | Hassan Nteifeh | 8 minutes readFor discussion, please check this reddit thread.
TLDR
- Exceptions are not represented at the type level, which means that they can be easily overlooked or handled incorrectly.
- By returning errors, developers are forced to handle them explicitly. This makes it more likely that errors will be handled correctly and that the application will remain stable.
- There are two common approaches for modeling success/failure types when returning errors: the union approach and the ROP/monadic approach.
- Returning errors is the preferred approach in most cases, but exceptions can still be useful in some cases.
It's not always the case that functions take the happy path; many things can go wrong:
- A database query might fail due to a connection failure.
- An API call might fail due to hitting a rate limit.
- A parser might encounter an unexpected token.
Error handling is about anticipating what errors might occur at runtime and having a strategy to deal with them. There are two components to this process:
- The broadcasting component: concerned with how we communicate the occurrence of errors.
- The modeling component: concerned with what data we emit when broadcasting errors and the shape of that data.
There are two common approaches by which errors are broadcasted:
- The Exception Approach: When an error occurs, an error is thrown by using the
throw
keyword. - The Computational Approach: Where errors are returned just like any other value.
In this article, I'll explain why in most cases—in the context of Typescript—the computational approach is better than the exception approach. We'll also discuss modeling errors.
Issues With The Exception Approach
Exceptions Are Invisible To The Caller
Exceptions are not represented at the type level. They're not part of the type signature of a function, so there is no way to tell at a glance from a caller's POV if a function might throw and what errors it might throw.
Consider the following example:
const someAsyncStuff = async () => {try {await action1()await action2()} catch(error) {// hmmmm was it action1 or action2 that threw the exception?// and what kind of error am I dealing with here?}}
If you hover over error
in the catch
clause, you'll see that Typescript infers the type of error
to be any
(or unknown
in strict mode), making it hard to interpret and handle in a meaningful way.
The only way to find out what errors we might be dealing with is by manually inspecting the implementation of every function used in the body of someAsyncStuff
; that is action1
and action2
and then check the functions used in the bodies of those functions, and so on.
But even if we do check the implementation of those functions, find out about all the possible exceptions, and handle them in the caller function, we'd still have no idea when the callee—the function being called—adds new types of exceptions or remove one since those changes are never communicated at the type level.
Not only that, but we also wouldn't have a feasible way in the code to check which function actually threw the exception. This leads us to the following question:
If there's no reliable way to anticipate what errors we should be expecting from the callee, how is the caller supposed to properly handle those errors in a catch clause?
Exceptions Are Prone To Abstraction Leak
If an exception is thrown at some layer M, and it gets caught as-is at some layer N—which is more than one layer away from layer M—without it being transformed in the layers in between, then it's most likely already too late to try and take some retry action or even interpret the error in a meaningful manner due to two simple reasons:
- Retry options are usually found at layer
M
(where the error occured) or its callee-layerM-1
. For example If a network call fails, it's usually layerM-1
(The calling layer) that might do a retry attempt, or layerM
itself might retry that recursively. - The semantics between layer
M
and layerN
are in all likelihood considerably different since they don't work directly with each other and are more than one layer apart.
Since exceptions are not observable at the type level, developers can easily overlook handling them, and they'll eventually reach layers incapable of handling them properly.
The Computational Approach
Exceptions' lack of representation at the type level is the root cause of their unreliable approach to error handling. To solve this issue, we can return errors instead of throwing them, and this is what the computational approach is all about.
The computational approach suggests using a type that encodes both possibilities of success and failure. This type is commonly referred to as Result
.
There are two common approaches for modeling this kind of success/failure type:
- The Union Approach.
- The ROP/Monadic approach.
Let's explore those approaches.
The Union Approach
This is the simplest of the two. Here, we model the result type like this:
type Result<Error, Value> =| {success: false,error: Error}| {success: true,value: Value}
With a structure like that, we can use the key success
to differentiate between success values and failure values.
To put things in their context, consider the following scenario:
- We have an endpoint
/vote
that enables users to vote on an issue. - The endpoint has a handler called
handleVoteOnIssue
. - As a first step of handling the request, we call
parseRequestBody
which will be responsible for making sure that the request body contains valid data before further processing. If the request body is valid then it will return success along with the parsed body, otherwise it will parseRequestBody
can fail with one of two types of errors:MissingKey
,TypeMismatch
.
class TypeMismatch extends Error {constructor(message: string) {super(message);this.name = "TypeMismatch";}}class MissingKey extends Error {constructor(message: string) {super(message);this.name = "MissingKey";}}type ParseRequestReturnType = Result<TypeMismatch | MissingKey,{ issueId: string, vote: string }>const parseRequestBody = (requestBody: unknown): ParseRequestReturnType => {if (!isObject(requestBody))return {success: false,error: new TypeMismatch(`Expected requestBody to be an object`)}if (!hasProperty(requestBody, 'vote'))return {success: false,error: new MissingKey(`Missing field "vote"`)}if (['upvote', 'downvote'].includes(requestBody.vote))return {success: false,error: new TypeMismatch(`Invalid value provided for key "vote"`)}if (!hasProperty(requestBody, 'vote'))return {success: false,error: new MissingKey(`Missing key "vote"`)}if (typeof requestBody['vote'] !== 'string')return {success: false,error: new TypeMismatch(`Expected field "vote" to be of type string`)}return {success: true,value: requestBody}}
And it would be used like this in the handler:
const voteOnIssueHandler = async (request, response) => {const parsingResult = parseRequestBody(request.body)if (!parsingResult.success)return response.json({errorMessage: formatErrorMessage(parsingResult)})// Othewise continue handling the request}
Here, voteOnIssueHandler
is aware of the fact that parseRequestBody
can fail, and exactly why it would.
Pros:
- Unlike errors in the exception approach, errors are reflected at the type level.
- This model is less prone to abstraction leaks as callers of result-returning functions have to assert success before accessing the happy value.
Cons:
- It can get tedious and noisy with a lot of assertions when multiple result-returning functions are involved.
- Provides no stack trace.
The Monadic/ROP Approach
The monadic/ROP(Railway Oriented Programming) approach is a big topic and beyond the scope of this article, but I'll briefly touch on it here. You can learn more about ROP in this great article.
With this approach, you don't only get the state of success/failure and the error/data, but also utility functions that you could use to chain and compose result-returning functions.
Let's take a look at the following code snippet that builds on our previous voting example:
const voteOnIssueHandler = async (request, response) => {const parsingResult = parseRequestBody(request.body)if (!parsingResult.success)return response.json({errorMessage: formatParsingErrorMessage(parsingResult)})const { issueId, vote } = parsingResult.valueconst userIdResult = getUserIdFromRequest(request)if (!userIdResult.success)return response.json({errorMessage: `You must be logged in order to vote`})const {userId} = userIdResult.valueconst votingResult = await votingModule.vote(issueId, vote, userId)if (!votingResult.success) return response.json({// Different errors require different messageserrorMessage: formatErrorMessageForVoting(voteResult)})return response.json({ success: true })}
Using a monadic approach our code would look something similar to this:
const voteOnIssueHandler = async (request, response) => {const result = awaitparseRequestBody(request.body).mapError(formatParsingErrorMessage).chain(parsedBody =>getUserIdFromRequest(request).map(userId => ({// No we have vote, issueId, and userId...parsedBody,userId}))).mapError(error => ({errorMessage: `You must be logged in order to vote`})).chain(({ issueId, vote, userId}) =>votingModule.vote(issueId, vote, userId))if (isError(result)) return response.status(400).json(result)return response.statuss(201).json(result)}
mapError
would be called whenresult.success === false
and gives access to the error value.mapError
would be called whenresult.success === true
and gives access to the happy value.chain
is used to...well, to sequentially run a function that also returns a result after the previous one has succeeded! think of it likePromise.then
.
Pros:
- It can reduce the code noise introduced by if-statements and be more visually appealing, especially to people who find piping and chaining visually and mentally appealing.
- Errors are represented at the type level.
- Just like the previous approach, less prone to abstraction leak.
Cons:
- Steep learning curve.
- Provides no stack trace.
Modeling Errors
So far, we've focused on how to communicate the occurrence of errors in the system; now it's time to
talk about how to structure those errors. Looking back at TypeMismatch
that we wrote earlier:
class TypeMismatch extends Error {constructor(message: string) {super(message)this.name = "TypeMismatch"}}return new TypeMismatch(`Expected requestBody to be an object`)
The biggest issue with this implementation is that we're only using the error message to convey the error details. This is problematic because:
- Error messages are strings, and strings are unstructured data, making it very challenging for the caller to extract valuable details and to create a custom error message to return to the user.
- If the error message is sent back to the user then this is probably an abstraction leak; the layer that threw/returned the error is now resposbile for not including sensitive information in its error message even if that information is useful for handling the error.
With that in mind, we want to model our errors so that:
- They provide details in a structured manner so that the function that receives the error can create a custom error message based on it.
- They provide a stack trace.
type IError<ErrorName extends string, ErrorPaylod = null> = {errorName: ErrorNameerrorPayload: ErrorPayloderrorMessage: string | nullstack: string | null}type TypeMismatchError = IError<'TypeMismatch', { expectedType: string, receivedType: string }>type MissingKeyError = IError<'MissingKey', { missingKey: string }>
Now that looks better, but what about providing a stack trace? to be able to do that, we need to do a little trick 🪄:
const getStack = (): string | null => {const errorObj = new Error()const propertyIsSupported = typeof errorObj?.stack === 'string'if (propertyIsSupported) {return errorObj.stack}console.log('error.stack is not supported')return null};
We usually don't have access to the stack trace except via errors constructed by the Error
class, so we've created the getStack
to get around that.
With this beautiful function in our pocket, let's create an error constructor that will give us the option to include the stack trace:
type ErrorOpt = { withStack: boolean }const createTypeMismatchError = (data: {payload: {receivedType: string;expectedType: string;};errorMessage?: string;},options?: ErrorOpt): TypeMismatchError => {return {errorName: "TypeMismatch",errorPayload: data.payload,stack: options?.withStack === true ? getStack() : null,errorMessage: data.errorMessage ?? null,};};
Note
it's worth noting that the stack
property is available via v8's Stack trace API. However, this is not a standard feature in JavaScript.
Wrap up
For all the reasons mentioned above, returning errors is the more robust and overall better approach. However, I still might use exceptions in very few situations, mainly when the function throwing the exception is a critical top-level function and there's no recovery option for its failure.
One such example is a function initAppConfig
that validates all the required environment variables and returns them in a typed config at the start of the application.
And that was it. I hope you enjoyed the article.