Date: 2025.05.28 | build | create | tech | types | typescript |
DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)
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:
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.
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.
return new Error(message)throw new Error(message)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:
Benchmark code is provided in full further on in the post.
In my benchmark run using the TypeScript playground, we get 1M iterations in:
Thus:
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.
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);
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.
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:
The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.