TypeScript - Errors as Values vs Exceptions Performance Benchmarks
Date: 2025-05-28 | build | create | tech | types | typescript |
I recently had another errors as values vs exceptions discussion at work. We use TypeScript which is generally an exception-leaning ecosystem but I personally prefer errors as values in most cases as I think they allow better leverage of most type systems (and I'm a fan of types).
To show how this worked to the team, I refactored a validateOrThrow function to a tryParseValidType function in a similar vein to Parse, don't validate - using the types to declare validity, not relying on someone before you doing the validation.
But to keep the changes minimal I decided to keep the exception types in there and simply remove the throws - allowing the exceptions to be the Error case of my Result type. This worked great for a demo as there was a clean diff between throwing the exception and returning the exception but returning exceptions like this is itself an antipattern for a number of reasons.
In general I don't like exceptions for several reasons:
- Exceptions take over all control flow unless someone happens to catch it. Most type systems do not help you understand what potential errors could be thrown so it leads to a lot of unhandled code paths and wrestles control away from the caller.
- Exceptions are meant for exceptional cases where we can't handle what happened. But most error cases can be handled nicely if we have enough info about what went wrong and returning errors as values allows control to stay with the caller so they can choose what to do with it.
- Exceptions are heavy - they often do a lot of internal computation for gathering stack traces and debugging info. This is very useful for when something unexpected happens as you have a bunch of info you can use to debug. But it's super wasteful if you did expect that exception as none of that extra info is actually helpful.
In this post we're going to pull on one of those reasons - performance and see how returning an error value compares to returning an exception.
Benchmarking errors as values vs exceptions
All benchmarks have asterisks and this one is no different. If you want a true understanding of how two things compare, you generally need to compare them in your production environment to account for all the different variables that could affect it.
But I think benchmarks can still give some directionally useful information so we'll forge ahead with ours.
The benchmark we'll use compares the creation of an Exception (Error in Node), throwing that exception, and the creation of an object describing that error.
- Exception -
return new Error(message)
- Thrown exception -
throw new Error(message)
- Error -
return { success: false, message: message, code: 500}
The shallow info provided in each is basically the same - a message, an indication that it was not successful, and generally a type of error that was returned. As we'll see an exception will also include extra info around the stack trace and line number it was created which is part of what we're trying to benchmark.
For the benchmark itself:
- Warmup - Run 10k times
- Run benchmark - Run 1M times
Benchmark code is provided in full further on in the post.
Errors vs Exceptions results
In my benchmark run using the TypeScript playground, we get 1M iterations in:
- Exception creation - 1113 ms
- Exception throw - 1172 ms
- Error - 3.3 ms
Thus:
- Returning an object is 337.33x faster (33633.33% faster) than creating an error
- Returning an object is 355.18x faster (35418.18% faster) than throwing an error
The reason that exception creation is so much slower is because it does extra work under the hood - like logging the stack trace, formatting the message, and doing any other things it wants to do.
The reason that throwing an exception is even slower than creating one is because it also incurs stack unwinding costs where it looks for an outer catch block.
These are trivial exceptions with shallow call stacks but we could imagine that deeper call stacks and larger distances between the throw and catch could make these even slower - whereas a simple object creation is the ~same no matter how deep it is.
Errors vs Exceptions Benchmark Code
If you want to run this benchmark yourself, you can grab the code below:
// Error Creation vs Object Return Benchmark
// This benchmark compares the performance difference between:
// 1. Creating an Error object
// 2. Throwing an Error object
// 3. Creating and returning an error-like object
interface ErrorResult {
success: false;
message: string;
code: number;
}
function throwError(message: string): void {
throw new Error(message)
}
function returnError(message: string): Error {
return new Error(message);
}
function returnErrorObject(message: string): ErrorResult {
return {
success: false,
message,
code: 500
};
}
// Helper function to measure execution time
function measureTime(fn: () => void, iterations: number): number {
const start = performance.now();
for (let i = 0; i < iterations; i++) {
fn();
}
return performance.now() - start;
}
// Run the benchmark
function runBenchmark(iterations: number = 1000000): void {
console.log(`Running benchmark with ${iterations.toLocaleString()} iterations...`);
const throwErrorTime = measureTime(() => {
try {
throwError("Something went wrong")
} catch(e) {}
}, iterations);
const errorTime = measureTime(() => {
returnError("Something went wrong");
}, iterations);
const objectTime = measureTime(() => {
const result = returnErrorObject("Something went wrong");
}, iterations);
// Calculate how many times faster
const errorOverObjectRatio = errorTime / objectTime;
const errorOverObjectPercentFaster = ((errorOverObjectRatio - 1) * 100).toFixed(2);
const throwErrorOverObjectRatio = throwErrorTime / objectTime
const throwErrorOverObjectPercentFaster = ((throwErrorOverObjectRatio - 1) * 100).toFixed(2);
// Display results
console.log("\nResults:");
console.log(`Exception creation: ${errorTime.toFixed(2)}ms`);
console.log(`* Average: ${(errorTime / iterations)}ms`)
console.log(`Error object return: ${objectTime.toFixed(2)}ms`);
console.log(`* Average: ${(objectTime / iterations)}ms`)
console.log(`Exception throw: ${throwErrorTime.toFixed(2)}ms`);
console.log(`* Average: ${(throwErrorTime / iterations)}ms`)
console.log(`\nReturning an object is ${errorOverObjectRatio.toFixed(2)}x faster (${errorOverObjectPercentFaster}% faster) than creating an error`);
console.log(`\nReturning an object is ${throwErrorOverObjectRatio.toFixed(2)}x faster (${throwErrorOverObjectPercentFaster}% faster) than throwing an error`);
}
console.log("=== Error Creation vs Object Return Benchmark ===\n");
runBenchmark(10_000); // Warm up
console.log("\n--- Full Benchmark ---");
runBenchmark(1_000_000);
When to use Exceptions vs Error Values
As usual it depends. But generally I think you should use exceptions rarely - in truly exceptional cases where you cannot recover. In those cases exceptions are great because they have so much debugging info attached to them.
In most other cases though I prefer errors as values. If you can expect the error then handling it explicitly is often better than letting it throw and hoping someone else handles it. This also implies that over time as you see exceptions in your system, it's usually best to start handling those cases explicitly such that most exceptions move to errors by value.
I often use Result types to help make errors by value a bit more clear and ergonomic to work with.
Next
So yeah errors as values are much faster than creating or throwing exceptions as expected. The exact amount they're faster by is going to depend on your own setup so if you're basing a decision on this, try testing it in prod.
If you liked this post you might also like:
Want more like this?
The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.