Skip to content

Results gently pass errors

Why throw when you can gently hand something over?

Traditional JS/TS error handling

Let’s say we want to make a function that calculates a percentage based on n, which is the number of points earnes, and total, which is the maximum number of points, and then formats that percentage into a string for display:

function percent(n: number, total: number) {
return `${n/total}%`;
}
console.log(percent(1, 2)) // "0%"
console.log(percent(1, 2)) // "50%"
console.log(percent(2, 2)) // "100%"

It works! And TypeScript looks pretty happy doesn’t it! Not a single squiggly line in sight!

Job well done, right?

Not so fast. percent() can be called in unsafe ways without raising any flags:

  • percent(2, 1) will return "200%"
  • percent(-1, 2) will return "-50%"
  • percent(0, 0) will return Infinity%!

So now your beautiful succinct function turns into this monstrosity:

type percentErrors = "DivByZero" | "TotalLessThanN" | "NegativeResult"
function percent(n: number, total: number) {
if (total < n) { throw "TotalLessThanN" }
if (total === 0) { throw "DivByZero" }
const p = return n / total;
if (p < 0) { throw "NegativeResult" }
return `${p}%`
}

And the errors have to be handled like this:

try {
console.log(percent(i, j))
} catch (error) {
switch error {
case "DivByZero":
console.log("0%")
break
case "TotalLessThanN":
console.log("100%")
break
case "NegativeResult":
console.log("0%")
break
}
}

But it’s fine, now your code has no undefined behavior! What a good programmer you are! Want a treat? Want a tasty, juicy TypeError?

Let me just throw that TypeError you wanted so much right at your face you fu-


Sorry, sorry, I’ve been traumatized by exceptions. I’m sorry for lashing out at you, it’s not your fault. You didn’t design the language. I’m sorry.

The problem is that you have to expect everyone to handle all the errors your function might throw:

try {
console.log(percent(i, j))
} catch (error) {
switch error {
case "DivByZero":
console.log("0%")
break
case "TotalLessThanN":
console.log("100%")
break
case "NegativeResult":
console.log("0%")
break
}
}

This is really ugly, but if you could assign a type to the error in the catch block, it would be fine. The compiler would tell you what errors to handle.

But since error is always typed as any, and you don’t have every possible throw value memorized, you’ll probably do this instead:

try {
console.log(percent(i, j))
} catch (error) {
console.log(error)
}

And you’re only going to do that after this throws in production on a Saturday at 2 AM and you’re trying to figure out why this function even threw because there’s no way to specify that it even could throw:

console.log(percent(i, j))

JavaScript tells you:

Types? You’re acting like you haven’t memorized every line of code in your node_modules folder. Kinda sus.

TypeScript goes:

What, you don’t know every possible error the libraries you use can throw? I’m already handling all these types and you want me to help with errors too? Can’t you do anything on your own? Pathetic.

I think there’s a nicer way.

Handle with care!

If vanilla error handling is equivalent to the way ${DELIVERY_COMPANY} (mis)handles packages, handling errors with Result<T, E> is like going out and manually delivering all your gifts by hand.

Let’s re-implement our percent() function with Result<T, E> now:

function percent(n: number, total: number): Enum<Result<string, Enum<PercentError>>> {
const p = n / total * 100;
if (total < n) return Err(pack("TotalLessThanN", p));
if (total === 0) return Err(pack("DivByZero", p));
if (p < 0) return Err(pack("NegativeResult", p));
return Ok(`${p}`);
}
const half = percent(1, 2);
match(half, {
Ok: (x) => console.log("ok:", x),
Err: (x) => console.log("error:", x)
});
const double = percent(2, 1);
match(double, {
Ok: (x) => console.log("ok:", x),
Err: (x) => console.log("error:", x)
});

Running the program will print:

Terminal window
[teo@koolaide crabrave]$ bun run examples/percent.ts
ok: 50%
error: [ "TotalLessThanN", 200 ]

Note that the error case is an array of length 2. This is actually an instance of our PercentError enum specified as the error type, and we can nest another match() to properly handle all the PercentError cases.

const double = percent(2, 1);
match(double, {
Ok: (x) => console.log("ok:", x),
Err: (err) => match(err, {
DivByZero: (x) => console.log("Divided by zero!", `${x}%`),
TotalLessThanN: (x) => console.log("Total is less than points earned!", `${x}%`),
NegativeResult: (x) => console.log("Negative percentage!", `${x}%`),
})
});

This match statement now logs Total is less than points earned! 200% to the console.

Or don’t!

Of course, while I don’t reccomend it, there is nothing physically stopping you from tossing and throwing your Results.

We can update our code like so:

const double = percent(2, 1);
console.log(double.unwrap())

Which would change our output to the following and crash our program:

Terminal window
[teo@koolaide crabrave]$ bun run examples/percent.ts
TypeError: Err(): TotalLessThanN,200
at /home/teo/crabrave/src/unwrap.ts:20:7
at /home/teo/crabrave/examples/percent.ts:24:12

Or at least, have some insurance.

What if you want the brevity of unwrap() with the safety of match()? You can use or(), which lets you specify a fallback value in case of an error:

const half = percent(1, 2);
console.log(half.or("0%"))
const double = percent(2, 1);
console.log(double.or("0%"))

Which makes our program return "0%" instead of crashing:

Terminal window
[teo@koolaide crabrave]$ bun run examples/percent.ts
50%
0%