Hugo Nteifeh

The Case For Returning Errors Instead of Throwing Them in Typescript

13 August 2022 | Hugo Nteifeh | 8 minutes read

For 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-layer M-1. For example If a network call fails, it's usually layer M-1 (The calling layer) that might do a retry attempt, or layer M itself might retry that recursively.
  • The semantics between layer M and layer N 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:

  1. We have an endpoint /vote that enables users to vote on an issue.
  2. The endpoint has a handler called handleVoteOnIssue.
  3. 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
  4. 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, and will expand on it in a future article. You can read more about ROP to check out this good 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.value
const userIdResult = getUserIdFromRequest(request)
if (!userIdResult.success)
return response.json({
errorMessage: `You must be logged in order to vote`
})
const {userId} = userIdResult.value
const votingResult = await votingModule.vote(issueId, vote, userId)
if (!votingResult.success) return response.json({
// Different errors require different messages
errorMessage: 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 = await
parseRequestBody(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 when result.success === false and gives access to the error value.
  • mapError would be called when result.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 like Promise.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: ErrorName
errorPayload: ErrorPaylod
errorMessage: string | null
stack: 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.

Hugo Nteifeh

© 2022-present Hugo Nteifeh. All Rights Reserved.