The 5 commandments of clean error handling in TypeScript

Marvin Roger
With Orus
Published in
9 min readMar 8, 2023

Dealing with errors is an essential part of software engineering.

Defining and having strong guidelines on how to handle errors will make your life easier when developing features, but also, and maybe more importantly when things go wrong!

At Orus (where we try to reinvent professional insurance), over time, we tailored an error strategy that works well for us and that we think could be useful to share.

While this post is mainly dedicated to error handling in TypeScript, some of the principles that we will go through are quite general and applicable to other languages as well.

Without further ado, here are our 5 error handling commandments:

  • #1: Make sure Errors are, well… Errors
  • #2: Don’t lose your stack trace
  • #3: Use constant error messages
  • #4: Provide the right amount of context
  • #5: Don’t throw errors for problems that are expected to happen

Did this peak your interest? If so, read on!

Commandment #1: Make sure Errors are, well… Errors

In the magnificent JavaScript world, you might not be aware, but you can throw anything, not only Error instances.

function throwNumber() {
throw 123
}

try {
throwNumber()
} catch (err) {
console.log(err) // 123
}

Although this is fun and allows some clever use cases (for example, the upcoming React Suspenses is built on the fact that we can throw promises), this is not a good idea! Indeed, there are a few issues:

  • There is no stack trace attached, so the thrown “error” loses much of its usefulness
  • On the caller side, almost always, actual Error instances are expected. It’s not unusual to see naive usage of err.message in the wild without checking the type of err first

To ensure you’re bulletproof to this matter, first and foremost, always throw Errors in your codebase. Having this rule in mind is great but enforcing it with an ESLint rule (https://typescript-eslint.io/rules/no-throw-literal/ for example) is even better!

More importantly: before using a caught error, make sure it’s actually an Error. To do this, make sure you’ve enabled the useUnknownInCatchVariables flag in your tsconfig.json (note that it’s enabled by default if you’re in strict mode). This will make the error variable in a catch block unknown instead of any.

try {
runFragileOperation()
} catch (err) { // err is `unknown`
console.log(err.message) // this will fail because we're not checking the `err` type
}

This is a great step forward: we cannot misuse an error without ensuring its type first. Out of the box, it’s not practical to use though:

try {
runFragileOperation()
} catch (err) {
if (err instanceof Error) console.log(err.message)

// what do you do if err is not an `Error`?
}

Constantly having to check the type of an error with an if block in a catch block is cumbersome. Moreover, what do you do if it’s not an Error?

The solution we’ve come up with in the Orus team is to have an ensureError function that makes sure an error is an Error. If it’s not, the thrown value is wrapped in an Error. The implementation looks like this:

function ensureError(value: unknown): Error {
if (value instanceof Error) return value

let stringified = '[Unable to stringify the thrown value]'
try {
stringified = JSON.stringify(value)
} catch {}

const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`)
return error
}

Armed with this function, manipulating caught errors is more straightforward:

try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)

console.log(error.message)
}

There are several benefits to doing this:

  • It handles absolutely everything that might be thrown, and as long as the value is JSON-stringifiable, no information is lost
  • It creates an Error instance if needed, adding a stack trace as early as we possibly can. Therefore, the error can be propagated easily with the most relevant stack trace
  • The usage looks way cleaner: with one short line, you’re sure you’re manipulating an Error. Because this has to be used in every catch block, this is quite important

This one simple function significantly simplified our error handling code.

Commandment #2: Don’t lose your stack trace

Can you spot the issue in this snippet?

try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)

throw new Error(`Running fragile operation failed: ${error.message}`)
}

Alright, the title gave it up quite early: we’re losing the stack trace of the initial error.

This does not seem like much, but having as much information as possible can help tremendously while debugging an issue.

While keeping the stack trace (“chaining” errors) was rather complicated in good old JavaScript, you’re in luck: starting with Node.js 16.9.0, and available in most browsers since mid-2021, the cause property allows you to attach an “original” error to an Error in a backward compatible way:

const error1 = new Error("Network error")
const error2 = new Error("The update failed", { cause: error1 })

console.log(error2)

/* Prints:

Error: The update failed
at REPL2:1:16
at Script.runInThisContext (node:vm:129:12)
... 7 lines matching cause stack trace ...
at [_line] [as _line] (node:internal/readline/interface:892:18) {
[cause]: Error: Network error
at REPL1:1:16
at Script.runInThisContext (node:vm:129:12)
at REPLServer.defaultEval (node:repl:572:29)
at bound (node:domain:433:15)
at REPLServer.runBound [as eval] (node:domain:444:12)
at REPLServer.onLine (node:repl:902:10)
at REPLServer.emit (node:events:525:35)
at REPLServer.emit (node:domain:489:12)
at [_onLine] [as _onLine] (node:internal/readline/interface:422:12)
at [_line] [as _line] (node:internal/readline/interface:892:18)
*/

As demonstrated, the original error is fully preserved, along with its stack trace. Note that it’s not limited to only one error, an error cause can have a cause which can have a cause which can have a cause… You get the idea!

If you’re wondering why we’re throwing a new Error instead of just re-throwing the original error, take the following example:

try {
runFragileOperation()
} catch (err) {
const error = ensureError(err)

if (!config.fallbackEnabled) throw error

try {
runFallback()
} catch {
// for the sake of this example, we discard the `runFallback`
// stack trace on purpose, but in course on production we shouldn't!
throw error
}
}

Compared to throwing new errors, we lose some benefits:

  • The error message might be less clear. You’ll probably prefer your function to fail with a nice Calling API failed rather than a more obscure read ECONNRESET
  • We don’t know which throw ended up throwing. Is it the one from the fallback or the other? The resulting stack trace would look exactly the same, which would not be the case had we thrown a new error

Commandment #3: Use constant error messages

Most monitoring and error tracking platforms analyze the error messages in order to determine if an error is frequent. Take the following example:

try {
await logRequest(requestId, { elapsedTime })
} catch (err) {
const error = ensureError(err)

throw new Error(`Could not log request with ID "${requestId}"`, { cause: error })
}

Imagine that logRequest logs the request in a database, and the database fails. If 1 000 requests fail due to this, there will be 1 000 different error messages:

Could not log request with ID "9r7S8_ZoobNwRKafaVeP7"
Could not log request with ID "v2zGvKj-JVdFg_vjJyUP1"
Could not log request with ID "CaU_eS8olPcbbxfIPiUWN"
[...]

This would be problematic, because:

  • If you’re a small company, your monitoring platform will probably alert you whenever there’s a single unexpected error. You’ll end up with 1 000 alerts
  • If you’re a bigger company, and your monitoring platform alerts you when a certain occurrence threshold is met, each error message being unique, you’ll never be notified

The solution to this is to make sure your error messages are constant. The variable data should be set as part of the error context (or metadata or any other name you prefer). For type-safety concerns, you’ll want to create your own subclass of Error:

type Jsonable = string | number | boolean | null | undefined | readonly Jsonable[] | { readonly [key: string]: Jsonable } | { toJSON(): Jsonable }

export class BaseError extends Error {
public readonly context?: Jsonable

constructor(message: string, options: { error?: Error, context?: Jsonable } = {}) {
const { cause, context } = options

super(message, { cause })
this.name = this.constructor.name

this.context = context
}
}

Now, you can attach context to an error, while making sure your error is serializable in JSON thanks to the Jsonable type. The above error would now look like this:

throw new BaseError('Could not log request', { cause: error, context: { requestId } })

Error grouping will then behave as expected, the error message always being the same! For debugging, you’ll still have access to the relevant data in context.

Commandment #4: Provide the right amount of context

This is a small one, but an important one nonetheless: attach the relevant data to your error context. Providing too little or too much context will make it harder for you to debug issues. Take the following example:

try {
await billCustomer(customer.id, quote.amount)
} catch (err) {
const error = ensureError(err)

throw new BaseError('Could not bill customer', {
cause: error,
context: { customer, quote }
})
}

There’s probably too much context here: chances are, to debug this, you’ll only need the customer.id and quote.amount. Debugging the issue, in this case, will require you to dig through the whole context, which might be huge (e.g. the quote could have a lot of information). Also, what if there are sensitive data in customer? Having your customer's personal information in your error monitoring platform is probably not something you want.

In this case, providing customerId and quoteAmount would have been enough.

Commandment #5: Don’t throw errors for problems that are expected to happen

Because of the nature of TypeScript, throwing errors should be considered a last resort, something that is hard or impossible to recover from.

Indeed, unlike other languages, it’s impossible in TypeScript to guarantee that thrown errors will be handled. This is due to the fact that thrown error types are not part of the function signature.

How can we guarantee that errors are handled, then? Well, errors should be part of the function signature. You can achieve this, for example, by leveraging the Result pattern, borrowed from Rust, which is simple but pretty powerful:

type Result<T, E extends BaseError = BaseError> = { success: true, result: T } | { success: false, error: E }

With this type, you can now express the fact that a function can fail. For example, a function reaching out to an API server would look like this:

type ApiResponse = { data: string }

export async function fetchDataFromApi(): Result<ApiResponse> {
try {
const result = await fetch('https://api.local')

// for the sake of this snippet we don't do it
// but we should validate `result` matches the expected format

return { success: true, result}
} catch (err) {
const error = ensureError(err)

return { success: false, error }
}
}

Now, it’s impossible to access the data from the API without making sure it was successful first. Also, notice that this function will never throw.

The above example is a bit rough and verbose to use, but this is simplified to the maximum for the sake of the demonstration. In practice, there are great libraries that expose nice APIs to work with Results efficiently and more elegantly, but this is outside the scope of this article.

In short, to make sure your users have the best experience possible, ask yourself the question “Will this function probably fail?”. If so, using the Result pattern is probably a good idea, as you have the guarantee that the failure case will be handled, so your users will see a nice message or an elegant fallback instead of a crash.

That’s it for this article! I hope that these tips that we’ve collected over time at Orus will be helpful to some extent to you.

Don’t hesitate to react to this article, if you have any questions or feedback, and I’ll gladly reply!

--

--