Rust-like error handling in TypeScript

Rust-like error handling in TypeScript

Published 2023/04/12. Edited 2023/04/13

One of Typescript's great flaws (among others) is the way errors are handled. There is no way to know whether any given function will throw an error (unless you've written it or you look at the source code). Did you know, for example, that the pre-spread operator favoured way of producing a deep object copy can throw an error?

JSON.stringify(JSON.parse(someObject));

One potential remedy to this would be the throws clause proposal linked here , but this has been open since 2017 (5 year at the time of writing), and it doesn't look like its going to turn up any time soon. It is, therefore, both down to the libraries to document whether their code throws an error (and what type is it if it does), and the developers to read the documentation and deal with the errors.

There is no silver bullet to typescript error handling: as one of the core principles of typescript is that it must be able to take javascript, major language changes are impossible. Some merit is found however, in the error handling practises of other languages. One option could be go down a Golang-esque route, and return any errors as a values.

Golang-style handling

In Golang, errors are returned as values - unlike Typescript there is no notion of try-catch. The advantage to this approach is that a function generating an error is explicitly specified in its return type: almost every function will have a return type of error, T with T being the actual value wanted.

// Instead of this
function throwsAnError(): number {
	const myVal = Math.random();

	if (myVal > 0.5) {
		throw new Error("Something went wrong");
	}

	return myVal;
}

// You can do this
function returnsAnError(): [number, Error] {
	const myVal = Math.random();

	if (myVal > 0.5) {
		return [myVal, new Error("Something went wrong")];
	}

	return [myVal, null];
}

// And check it like this

function someFn() {
	const [val, err] = returnsAnError();

	if (err !== null) {
		// handle the error
	}
}

The issue with this approach is twofold: firstly, the constant checking becomes quite cumbersome - a problem shared with Golang. Secondly, this method of error-handling is designed for Golang, which permits the returning of multiple values from a function without the need for arrays, and allows the reuse of one error variable instead of forcing the creation of new ones each time (you could also use let when destructuring but that's uncommon). A side note is that when you destructure you create a copy of the Error generated (meaning that the Garbage Collector will have to deal with it at some other random point in your program resulting in an increasing performance hit the larger your program gets) - though if you're really concerned about performance, you shouldn't be using Typescript.

Oxidising Typescript

Another, perhaps more idiomatic way of handling errors is by using Rust-inspired structures. As there is more boilerplate associated with dealing with these errors, we'll call on an external library, rustic , which abstracts this away to provide a similar interface to that used in Rust. Using the same example as before, we can see the simplicity of syntax that this provides in comparison to the Golang solution:

import { Result, equip, Ok, Err } from "rustic";

function returnsAResult(): Result {
	const myVal = Math.random();

	if (myVal > 0.5) {
		return equip(Err("Something went wrong"));
	}

	return equip(Ok(myVal));
}

function someFn() {
	const r = returnsAResult();
	if (r.isErr()) {
		// handle error
	}

	const value = r.unwrap();
}

The rustic Result takes two Type arguments: the success type (the value returned), and the error type (what would be thrown). Whilst they can both be anything, the error type should almost always be of type Error (or anything extending from Error) otherwise all the effort gone to to achieve safe error handling would be in vain without a stack trace.

When you use this, you can always be guaranteed that any function that returns a Result will never throw if implemented correctly. By using the rustic Result you gain access to helper methods (such as unwrap or isErr) which can be used for more powerful uses.

If an error is non critical you can utilise the method unwrapOr to provide it with a default value and suppress and error from being thrown (like in rust if unwrap is called and there is an error, it will itself throw an Error).

function getUpdates(): Result {
	if (res.code !== 200) {
		// request from an api for example
		return equip(Err(res));
	}

	return equip(Ok(res.data));
}

function callForUpdate(originalData: MyType) {
	const r = getUpdates();

	// Log that the request failed but continue on
	if (r.isErr()) {
		console.log(r.unwrapErr().toString());
	}

	return r.unwrapOr(originalData);
}

In this example, some hypothetical update data is gotten from an API. If the request fails for whatever reason, there is no point in breaking the entire application if we already have pre-existing data. The fact that the request failed can still be logged, but the we can always return a value from this.

It is inevitable that you'll eventually have to utilise some external library in your code, and the likelihood is that there will be cases in which it will throw an error (known or unknown). Luckily for us, rustic also has the wrapper for this: catchResult.

import { catchResult, equip } from "rustic";

const result = equip(catchResult(someOtherFunction));

if (result.isErr()) {
	// etc ...
}

A similar thing can be done for promises (albeit you have to write it yourself and the rejection type of a promise is any). If you know for certain the type of promise rejection you can specify it, otherwise you're stuck with the dastardly any type.

function promiseResult(myPromise: Promise): Promise> {
	myPromise
		.then((ok) => {
			return equip(Ok(ok));
		})
		.catch((e) => {
			return equip(Err(e));
		});
}

async function getData() {
	// Await can be done without try-catch as this can't throw
	const r = await promiseResult(someFn);

	if (r.isErr()) {
		const err = r.unwrapErr();

		if (err instanceof Error) {
			// Narrow things down if you don't know its type
		}
	}
}

The Caveats

Whilst the rustic solution is very clean and has a powerful architecture, it comes with several downsides:

  1. Abstractions cost performance.
  2. Pattern matching does not exist.

For every error handled, the equip methods creates a new instance of a class, which would add up over time and cause heavy GC spikes (like the Golang-type destructuring would). This would ultimately result in performance losses, which whilst not the primary focus of typescript, are undesirable. Pattern matching that is present in Rust makes dealing with results even more idiomatic: whilst not a drawback in its own right, it is certainly missed in the Typescript implementation.

A solution to the first problem could be reusing already-initialised classes by reassigning their inner Result. Whilst adding complexity, the savings found in a large codebase would be substantial. This would, however require changing the source and proactive clearing of each Result when it has been used.

Whether you should use either of these two error handling techniques, or stick with try-catch is a personal decision. Whilst smaller projects probably won't benefit from more rigorous error handling - larger codebases can get very chaotic very quickly, so a more robust way than try-catch would be useful. This has to be balanced against the performance costs of the more robust solutions, and the time spent implementing them - particularly if old codebases are being converted. If you're not sure, give it a try on a small project and see for yourself whether it's worth it for you.

Edit 2023/04/13:
Fixed Golang example to return in the right order