How we do Rust inspired, human-friendly error-handling for JavaScript

Learn how we do Rust inspired, human-friendly error-handling at Flytrap, and what open-source JS libraries we use to achieve this.

Rasmus Gustafsson

Cover image for blog post about "How we do Rust inspired, human-friendly error-handling for JavaScript"

Learn how we do Rust inspired, human-friendly error-handling at Flytrap, and what open-source JS libraries we use to achieve this.

Good error handling is hard.

In this blog post we will go through the importance of good error handling and error logs for a product and some of the papercuts that might happen with traditional error handling. Lastly, we will look at how we solved these problems using Rust inspired error handling with a library called ts-results for Results, and human-logs (built by us) for structured and type-safe log / error messages.

TLDR;

We use a combination of ts-results and human-logs to create human-friendly logs in a intuitive way that makes great error handling and human-friendly logs built-in to our codebase.

For instance, if we have an example of creating an API key. This is what it would look like with our approach to error handling:

Example of what our logs would look look like:

export const createLog = createHumanLogs({
events: {
api_key_create_failed: {
template: 'Creating an API key for team "{teamId}"',
params: { teamId: '' } // 👈 these are inferred like magic when creating logs!✨
}
},
explanations: {
database_unreachable: 'because the database is unreachable',
hashing_failed: 'because hashing string failed.',
},
solutions: {
view_status_page: {
template: 'Check our status page for the status of our services',
params: {},
actions: [
{ text: 'View status page', href: 'https://status.useflytrap.com' }
]
},
join_discord: {
template: 'Our Discord community is the fastest way to get help with your problem.',
params: {},
actions: [
{ text: 'Join Discord', href: 'https://discord.gg/tQaADUfdeP' }
]
},
}
})

Then we would write our code like this using ts-results:

import { Ok, Err } from 'ts-results';
import { createLog } from '@/errors';
function generateKeyHash(): Result<string>;
// Here we return the hash if everything goes well and return
// an `Err` object with the `human-log` instance that includes the explanation
function hash(inp: string) {
try {
return Ok(doHashingHere(inp))
} catch {
// Btw, the below `explanations` are inferred, so they have autocomplete!
return Err(createLog({ explanations: ['hashing_failed'] }))
}
}
export async function createApiKey({ hash, teamId }) {
try {
return Ok(await db.keys.create({
data: { hash, teamId }
}))
} catch {
// In reality, you would handle different database errors separately
return Err(createLog({
explanations: ["api_unreachable"],
solutions: ["view_status_page"]
})
}
}
// Look how clean our implementation of `createApiKey` becomes, complete
// with great error handling.
async function createApiKey(teamId: string) {
return generateKeyHash()
.andThen((hashStr) => hash(`sk_${hashStr}`)
.map((hash) => ({ hash, teamId }))
.andThen(createApiKey)
}

And consume it like this:

const apiKeyResult = createApiKey();
if (apiKeyResult.err) {
const log = apiKeyResult.val.addEvents(["api_key_create_failed"], { teamId: 'flytrap' })
return new Response(log.toString(), { status: 500 })
// 👆 now we have user-friendly logs
// the above will output; "Creating an API key for your team "flytrap" failed because the database is unreachable. Check our status page for the status of our services. View status page (https://status.useflytrap.com)
}
return new Response(JSON.stringify({ apiKey: apiKeyResult.val })

We get super user-friendly errors like:

“Creating an API key for your team "flytrap" failed because the database is unreachable. Check our status page for the status of our services. View status page (https://status.useflytrap.com)”

Read on to see the more in-depth write-up!

Why error handling matters for good products

Good error messages increase customer satisfaction, improve customer satisfaction and trust in your product, and minimize the amount of support requests. Good error messages act as a sort of guide, helping your users achieve what they wanted to achieve. Because of this, investing in good error messages is a win-win for both the creators of software and its users.

Read more about good error design from Vercel’s error design framework.

The problems of traditional (exception based) error handling in JavaScript

Exceptions introduce a completely new separate control flow for your code. This is generally dangerous in JavaScript, since you don’t have syntax like “throws” that you have in for instance Java, explicitly showing which exceptions a function can throw. As a result, you often don’t know if a function might throw, without a round-trip to the documentation of said code to see if it throws something.

Also, using try / catch causes extra problems with variables only being available in the try scope.

Because of this, handling errors is super difficult, consider the following example;

try {
const body = schema.parse(await req.json())
// ...
} catch (e) {
if (e instanceof ZodError) {
return Response('Invalid Request Body Schema', { status: 400 })
}
return Response('Internal Server Error', { status: 500 })
}

You need to know explicitly, that schema.parse throws a ZodError, to be able to handle errors gracefully.

Even when we’re handling all cases of exceptions throw by our own codebase, it presents problems.

For example; let’s say that you have a product where users can create API keys. Super usual stuff for developer tools. During the creation of your API key, you may create a random string, append it to create some key like `sk_aa4fg6h...`, hash it, and create a database entry all of which can throw, example pseudocode;

async function createApiKey() {
const secretKey = `sk_${generateKeyHash()}`
const hashedApiKey = hash(secretKey)
const apiKeyEntry = await db.keys.create({
data: { ... }
})
}

Now when you consume this code, all of the above functions might throw (generateKeyHash(), hash() or db.keys.create())

Here we’re going with a “Let it Throw” approach to error handling, and just delegating the error-handling to any function consuming said function (and praying that they realize that said function can throw errors)

Because you don’t know which one of them threw, you have to resort to a generic error like “Something went wrong while creating your API key”. Your error handling might look like something like this:

try {
const apiKey = await createApiKey()
return new Response(JSON.stringify({ apiKey })
} catch {
return new Response(
'Oops, something went wrong while creating your API key',
{ status: 500 }
)
}

Our approach to error-handling

So, how do we solve these problems? We combine human-logs, an open-source library built by us to solve this very problem, with a library called ts-results that allow us to do error handling inspired by Rust.

We start by defining our errors in human-logs, following Vercel's error design framework. It's a fantastic resource, but in a nutshell, we want to honestly communicate the following; what happened ("events"), why it happened ("explanations"), and possible solutions to the problem ("solutions")

Here’s what example human-logs might look like for the case of creating an API key:

import { createHumanLogs } from 'human-logs'
export const createLog = createHumanLogs({
events: {
api_key_create_failed: {
template: 'Creating an API key for team "{teamId}"',
params: { teamId: '' } // 👈 these are inferred like magic when creating logs!✨
}
},
explanations: {
database_unreachable: 'because the database is unreachable',
hashing_failed: 'because hashing string failed.',
},
solutions: {
view_status_page: {
template: 'Check our status page for the status of our services',
params: {},
actions: [
{ text: 'View status page', href: 'https://status.useflytrap.com' }
]
},
join_discord: {
template: 'Our Discord community is the fastest way to get help with your problem.',
params: {},
actions: [
{ text: 'Join Discord', href: 'https://discord.gg/tQaADUfdeP' }
]
},
}
})

Intuitive, isn’t it!?

We have a single place for all of our logs, instead of having a bunch of log messages scattered. This makes logs super easy to manage when things change (domains change, the URL for some docs changes, someone accidentally invalidates the Discord invite link, etc). Also, having generic “fallback” solutions such as our `join_discord` one becomes super easy to re-use throughout the codebase.

Now, onto the ts-results part, here's how we would write the above code with Rust inspired errors, with ts-results (pseudocode):

import { Ok, Err } from 'ts-results';
import { createLog } from '@/errors';
function generateKeyHash() {
return Ok('.. hash ..')
}
// Here we return the hash if everything goes well and return
// an `Err` object with the `human-log` instance that includes the explanation
function hash(inp: string) {
try {
return Ok(doHashingHere(inp))
} catch {
// Btw, the below `explanations` are inferred, so they have autocomplete!
return Err(createLog({ explanations: ['hashing_failed'] }))
}
}
export async function createApiKey({ hash, teamId }) {
try {
return Ok(await db.keys.create({
data: { hash, teamId }
}))
} catch {
// In reality, you would handle different database errors separately
return Err(createLog({
explanations: ["api_unreachable"],
solutions: ["view_status_page"]
})
}
}
// Look how clean our implementation of `createApiKey` becomes, complete
// with great error handling.
async function createApiKey(teamId: string) {
return generateKeyHash()
.andThen((hash) => hash(`sk_${hash}`)
.andThen(hash)
.map((hash) => ({ hash, teamId }))
.andThen(createApiKey)
}

Now, consuming the createApiKey function becomes MUCH more intuitive, no more try / catch; look below;

const apiKeyResult = createApiKey();
if (apiKeyResult.err) {
const log = apiKeyResult.val.addEvents(["api_key_create_failed"], { teamId: 'flytrap' })
return new Response(log.toString(), { status: 500 })
// 👆 now we have user-friendly logs
// the above will output; "Creating an API key for your team "flytrap" failed because the database is unreachable. Check our status page for the status of our services. View status page (https://status.useflytrap.com)
}
return new Response(JSON.stringify({ apiKey: apiKeyResult.val })

As you can see, the above code gives us a human-friendly error-message, which guides them to a solution:

“Creating an API key for your team "flytrap" failed because the database is unreachable. Check our status page for the status of our services. View status page (https://status.useflytrap.com)”

Also, as an added benefit, if we feel like being lazy programmers, we can just .unwrap() and still get more user-friendly exceptions thrown, that will be more helpful to your engineers when trying to understand what went wrong;

const apiKey = createApiKey().unwrap()
// Error: Tried to unwrap Error: Creating an API key for your team "flytrap" failed because the database is unreachable. Check our status page for the status of our services. View status page (https://status.useflytrap.com)

Also, the ts-results library comes with other useful utilities such as Result.all that returns the Results as an array, or returns the first Err object that happens.

If we want to do something like create a team, and then create a default API key, it becomes super easy;

import { Ok, Err, Result } from 'ts-results';
import { createLog } from '@/errors';
const createTeamAndKeyResult = Result.all(
createTeam,
createApiKey
)
if (createTeamAndKeyResult.err) {
// Now, depending on which of the functions failed, we will have their error here.
return createTeamAndKeyResult.val.toString()
}
// otherwise, we can conveniently access the values from the Result.
const [createdTeam, createdApiKey] = createTeamAndKeyResult.val

Quite the improvement:

— We made sure that there cannot be exceptions that trigger a surprise change of control flow.

— All errors that can happen are fully typed, and will now be handled, or explicitly thrown with the unwrap() function.

— Everything stays in the same scope, and we don’t need try / catch.

— We get much more user-friendly error messages that will communicate what went wrong and how to solve it to the end-user. When users report these errors to your engineers, they will better understand what went wrong, and which function caused the error.

— Errors get bubbled up from the low-level functions, allowing us to get an understanding of why something went wrong (”explanations”) from the low-level functions, and we can use functions like addEvents to add the high-level explanation of what went wrong (”events”)

Resources

Vercel’s error design framework

human-logs GitHub

ts-results GitHub

Async variant of ts-results