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.

Technical 11 min read

Take this function signature and tell me how it fails:
function loadConfig(path: string): ConfigYou 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/catchhides failures from the type system. You can't see what a function throws, the error you catch isunknown, and nothing warns you when you forget to catch. Rust'sResult<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 branchYou 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.
readFileandJSON.parsethrow, because that's what the platform does.tryCatchAsyncandtryCatchcatch them right there, around one call each, and turn them into typed errors, so notryblock 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
TypeErrorisn'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
Resultfor 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()andresult.expect("reason")do that: they throw if you were wrong about a result beingOk.
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,isErrmap,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
try/catchhides failures from the type system: you can't see what's thrown, the caught error isunknown, and code that forgets to catch still compiles.- Rust's
Result<T, E>puts failures in the return type, where they're typed and checked. - TypeScript's discriminated unions and type guards suit
Resultwell, including exhaustive checks on error kinds. - Catch exceptions at the boundary, around one call, and turn them into typed errors.
- Keep
throwfor bugs, and returnResultfor everything a caller should handle. - 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
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.
Related reading
Technical · How to deploy Procaptcha on your website or app How to switch from Google's reCAPTCHA to Prosopo Procaptcha and protect your website or app from bots for free.
CAPTCHA · TypeScript: Mapped Type Magic TypeScript mapped types can make Cat === Dog — a weird paradox. How to brand identical structural types so the compiler can tell them apart.
CAPTCHA · Vite: How to handle .node files Vite can't bundle .node native binaries — it treats them as raw JS and blows up. Our custom rollup plugin that copies .node files through the build.
Technical · Using Vite To Rebuild Local Dependencies in an NPM Workspace Vite ignores local workspace packages because they sit outside its module graph — the config trick that forces Vite to rebuild sibling npm packages.
CAPTCHA · TypeScript: Branded Types How to brand TypeScript types for norminal typing, providing an automated and easy-to-use way of making a type nominal.
CAPTCHA · actions/cache@v3 restore: GitHub Actions Cache Fix Fix GitHub Actions caching with actions/cache@v3 and actions/cache/restore: share caches across branches and evict old caches faster.Ready to ditch Google reCAPTCHA?
Procaptcha is free for 10K requests a month. No credit card, no cookies.