We stopped throwing errors in TypeScript and used Rust's Result instead

A TypeScript function signature tells you everything except how it can fail. Rust solved this years ago by putting failures in the return type with Result, and TypeScript's discriminated unions and type guards make the same pattern a natural fit. We published ours as @prosopo/result.

George Oastler
George Oastler

Technical 11 min read

Take this function signature and tell me how it fails:

function loadConfig(path: string): Config

You can't. It might throw if the file is missing or the JSON is invalid, it might throw a ZodError if the shape is wrong, or it might return a half-filled Config and log a warning. The signature says it returns a Config and nothing else, and TypeScript believes it.

That's the problem with exceptions: they're part of a function's behaviour, but the type system knows nothing about them.

Short version: try/catch hides failures from the type system. You can't see what a function throws, the error you catch is unknown, and nothing warns you when you forget to catch. Rust's Result<T, E> puts failures in the return type instead. TypeScript's discriminated unions and type guards suit that pattern well, so we use it, and we've published our implementation as @prosopo/result.

Three things try/catch gets wrong

1. You don't know what can be thrown

Java at least had checked exceptions. In JavaScript any function can throw anything (an Error, a string, undefined, a Promise), and nothing in the language records it. To find out what loadConfig throws, you read its source and the source of everything it calls, and then hope nobody changes any of it.

2. The error you catch is unknown

Even when you do catch, TypeScript can't help:

try {
	const config = loadConfig("./config.json");
	start(config);
} catch (error) {
	// error: unknown
	if (error instanceof SyntaxError) {
		// bad JSON... or a bug in start() that happened to throw a SyntaxError?
	}
}

error is unknown, and it has to be, because anything inside the try could have thrown anything. This catch also catches every bug in start(), so a typo that throws a TypeError gets swallowed by error handling that was written for a missing file.

3. Nothing tells you when you forgot

This compiles without a single warning:

const config = loadConfig("./config.json");

If the file is missing, the exception climbs the stack until something catches it or it takes down the process. The compiler had no way to warn you, because the possibility of failure was never written down anywhere it could see.

Rust puts failures in the return type

Rust doesn't use exceptions for expected failures. A function that can fail says so in its return type:

fn load_config(path: &str) -> Result<Config, ConfigError>

Result is an enum that is either Ok(value) or Err(error). To get at the Config, you have to deal with the possibility that it isn't there:

match load_config("config.json") {
    Ok(config) => start(config),
    Err(ConfigError::NotFound) => use_defaults(),
    Err(ConfigError::Invalid(reason)) => exit_with(reason),
}

The failure modes are in the signature, they're typed, and the compiler checks that you've handled them. If someone adds a new ConfigError variant, every match that doesn't cover it stops compiling.

That's what we want in TypeScript.

TypeScript already has the pieces

TypeScript has no Result, but it has the two features you need to build one: discriminated unions and type guards.

A Result is just a union with a tag:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

Checking the tag narrows the type, through TypeScript's control-flow analysis:

const result = loadConfig("./config.json");

if (result.ok) {
	start(result.value); // result.value: Config
} else {
	result.error; // result.error: ConfigError
}

result.value; // ❌ compile error: value doesn't exist on the error branch

You can't reach the Config without going through the check, and try/catch can never give you that guarantee.

Because the error is a typed union too, you can narrow it further and have the compiler check that you've covered every case:

type ConfigError =
	| { kind: "not-found"; path: string }
	| { kind: "invalid-json"; message: string }
	| { kind: "invalid-shape"; issues: string[] };

const describe = (error: ConfigError): string => {
	switch (error.kind) {
		case "not-found":
			return `No config at ${error.path}`;
		case "invalid-json":
			return `Config isn't valid JSON: ${error.message}`;
		case "invalid-shape":
			return `Config has problems: ${error.issues.join(", ")}`;
	}
};

If you add a fourth kind later, describe stops compiling until you handle it, which is what Rust's exhaustive match gives you, at no extra cost.

Type guards make it nicer still. With a guard function you can filter a list of results, and TypeScript keeps track of what's left:

import { isErr, isOk } from "@prosopo/result";

const results = paths.map(loadConfig); // Result<Config, ConfigError>[]

const configs = results.filter(isOk).map((r) => r.value); // Config[]
const errors = results.filter(isErr).map((r) => r.error); // ConfigError[]

There's no equivalent with exceptions.

A worked example

This is loadConfig written with @prosopo/result:

import { readFile } from "node:fs/promises";
import { err, ok, tryCatch, tryCatchAsync, type Result } from "@prosopo/result";
import { z } from "zod";

const ConfigSchema = z.object({ port: z.number(), host: z.string() });
type Config = z.infer<typeof ConfigSchema>;

const loadConfig = async (
	path: string,
): Promise<Result<Config, ConfigError>> => {
	const file = await tryCatchAsync(readFile(path, "utf8"), () => ({
		kind: "not-found" as const,
		path,
	}));
	if (!file.ok) {
		return err(file.error);
	}

	const json = tryCatch(
		(): unknown => JSON.parse(file.value),
		(error) => ({ kind: "invalid-json" as const, message: String(error) }),
	);
	if (!json.ok) {
		return err(json.error);
	}

	const parsed = ConfigSchema.safeParse(json.value);
	if (!parsed.success) {
		return err({
			kind: "invalid-shape",
			issues: parsed.error.issues.map((issue) => issue.message),
		});
	}
	return ok(parsed.data);
};

Compared with the try/catch version:

  • Exceptions stop at the boundary. readFile and JSON.parse throw, because that's what the platform does. tryCatchAsync and tryCatch catch them right there, around one call each, and turn them into typed errors, so no try block ever covers more than the one call that can fail.
  • Errors are passed on explicitly. return err(file.error) hands the typed error up to the caller, much like Rust's ? operator but spelled out.
  • The signature is honest. Promise<Result<Config, ConfigError>> tells every caller what can go wrong without anyone reading the body.
  • Bugs still throw. A typo elsewhere that throws a TypeError isn't swallowed by error handling meant for a missing file, so it crashes loudly, which is what a bug should do.

If you prefer chaining to early returns, Result has the Rust-style methods too:

const port = (await loadConfig("./config.json"))
	.map((config) => config.port)
	.unwrapOr(8080);

const message = (await loadConfig("./config.json")).match({
	ok: (config) => `Listening on ${config.host}:${config.port}`,
	err: describe,
});

Keep throwing for bugs

Using Result doesn't mean banning exceptions. Rust still has panic!, and the same split works well in TypeScript:

  • Return a Result for failures a caller is expected to handle: bad input, a missing record, a timeout, a payment that was declined.
  • Throw for bugs and broken invariants, the "this should never happen" cases where the right answer is to stop. result.unwrap() and result.expect("reason") do that: they throw if you were wrong about a result being Ok.

The aim is to make every expected failure visible in a type, rather than to remove every throw.

JavaScript would benefit too

I think JavaScript should adopt it. Plain JavaScript gets most of the benefit too, because even without the compiler, if (result.ok) is clearer than guessing what a function throws. There's no Result in the language yet. There is a proposal for a try operator that returns a Result, but it's a long way from being standard.

Until then you can adopt the pattern one function at a time. Start at the edges, where your code meets files, networks and user input, and let typed errors flow inward from there.

We open-sourced ours

We'd had a small Result type in our own codebase for a while. It typed its errors as unknown, which is the very problem this post is about, so we rewrote it with typed errors and the Rust-style API and published it:

npm install @prosopo/result
  • Source: github.com/prosopo/result
  • ok, err, tryCatch, tryCatchAsync, all, isOk, isErr
  • map, mapErr, andThen, orElse, match, unwrap, expect, unwrapOr, unwrapOrElse, unwrapErr
  • No dependencies, ESM and CommonJS, 100% test coverage

Issues and pull requests are welcome.

What we build

That codebase is the one behind Procaptcha and Prosopo Protect. Procaptcha is a drop-in replacement for reCAPTCHA and hCaptcha that is cookieless by default and free for the first 10,000 verifications a month, and Protect stops bots at the edge, before they reach your site. If bots are hitting your signup or login forms, start free or get in touch.

TL;DR

  1. try/catch hides failures from the type system: you can't see what's thrown, the caught error is unknown, and code that forgets to catch still compiles.
  2. Rust's Result<T, E> puts failures in the return type, where they're typed and checked.
  3. TypeScript's discriminated unions and type guards suit Result well, including exhaustive checks on error kinds.
  4. Catch exceptions at the boundary, around one call, and turn them into typed errors.
  5. Keep throw for bugs, and return Result for everything a caller should handle.
  6. Try @prosopo/result.

Questions

Something else? Ask an engineer.

Why is try/catch bad in TypeScript?

Nothing in a function's type says it can throw, or what it throws. The caught value is typed unknown, so the compiler can't help you handle it, and it can't warn you when you forget to catch at all. Errors become invisible control flow.

What is a Result type?

A return type that holds either a success value or an error, such as Result. The possible failures are part of the function's signature, so callers have to check which one they got before they can use the value.

Does TypeScript have a built-in Result type?

No. TypeScript's discriminated unions and type guards make one easy to write, and there are libraries for it. We published ours as @prosopo/result on npm.

Should I never throw in TypeScript?

Throw for bugs and broken invariants, the cases where crashing is the right answer. Return a Result for failures a caller is expected to handle, such as invalid input, missing records and network errors.

Ready to ditch Google reCAPTCHA?

Procaptcha is free for 10K requests a month. No credit card, no cookies.