5 Reasons to Stop Throwing Exceptions - and What To Do Instead for a more Robust, Composable, and Performant Codebase
Date: 2025-07-02 | create | errors | exceptions | programming | software | software-engineering | tech |
Nulls may have been the billion dollar mistake but exceptions are frequently the cause of bringing down prod.
Here we'll walk through 5 reasons you should stop throwing exceptions in your codebase and an alternative that often leads to a more robust, composable, and performant codebase.
Reasons exceptions suck and you should stop using them:
1. Exceptions break the type system
Exceptions are invisible in most languages. There's no way to tell if a function (or any of those it calls) throws a particular exception unless you step through all the code yourself. At scale it's impossible to know ALL code in your system so if the type system isn't helping you find those missed cases it's often going to go unfound.
This is one of the big reasons type-safe systems are useful at scale - they help you understand what your code can and can't do at scales where you can no longer fit the full system into your mental model.
But no matter how nicely typed your code is - exceptions negate those types making it harder to catch all cases and thus leaving more possibilities for errors to reach the end user.
Note: Some exception-based languages do support exception declarations, often called checked exceptions. Java is probably the best example of this.
2. Exceptions make it hard to handle all possible cases
Because the type system can't help you identify all possible exceptions a function (or those it calls) throws, you often leave some cases unhandled. Sometimes this is intentional if you don't want to handle those cases but oftentimes it's just because you didn't know the possibility existed.
To handle this, exception-based languages often end up doing catch alls - catching all exceptions or at least all exceptions of a given type.
This certainly works for preventing a raw exception from bubbling up to the surface but leaves a lot to be desired in terms of granularity.
- Surely there's a difference in recoverability / handling between an InvalidInputException and a TooManyRequestsReceivedException
- Even within an InvalidInputException, there's likely different responses we want to provide the user depending on the field they input wrong. We can throw an exception for this but is that the most ergonomic / performant way to model this info?
For exceptions where you can't recover, this blunt handling makes sense - there's nothing you could / would do about it anyway so why bother with all the possible cases?
But most errors are recoverable and with no type system to help you understand all possible return values it's easy to implicitly let recoverable errors go unhandled leading to unexpected behavior in production.
3. Exceptions break the control flow of your system
Exceptions use a different control flow than basically any other programming language feature. They are glorified goto statements that bypass everything except for try / catch blocks your callers may have implemented.
This makes it harder to reason about and fail gracefully with, leading to less robust systems.
4. Exceptions are slow, heavyweight operations
Exceptions are typically pretty slow constructs. They usually take a copy of the stack trace and then roll up the callstack to try and find the outer try / catch.
This is super useful for debugging errors and as a backstop for when unexpected things happen in your system but is very wasteful if the error is something you expect to happen and will handle / recover from as most of that extra info will just be tossed anyway.
In my TypeScript microbenchmarks, exceptions ran ~300x slower than just using error values.
5. Exceptions make it harder to compose robust systems
Composability is all about how easy it is to arrange / rearrange the logic blocks of your system into a new system that does smth with decent quality. In general a great system is highly composable which means building with it feels like building with legos - the pieces can fit together in a variety of ways and each output has reasonable robustness, testability, and performance.
Because exceptions lack type safety, break control flows, and don't surface their possible cases they are extremely hard to compose into a system without robustness issues. Each block that throws can now break all the blocks in the system and there's no way to know if that block throws or not.
The "safe" approach then is to wrap every single block with a try / catch but that's not very ergonomic, removes the caller's ability to granularly handle exceptions, and may over catch if some exceptions SHOULD bubble up to the end user / our outer logging pipeline.
So blocks that throw exceptions are basically footguns waiting to go off and reduce the robustness / composability of the entire system that uses it and thus are blocks you should generally avoid.
What should you use instead of exceptions?
So exceptions lead to less robust, composable, and performant systems. What should you use instead?
The answer is generally errors as values. We already return successes as values, why not errors?
These have several benefits including:
- Fully type-safe - it's just a value! So you can make types for your errors just as you can with a regular success payload.
- Uses regular control flows - it's just a value! So you can do all your if / else stuff and have confidence those things will actually get run - not magically skipped over because some random block decided to throw something.
- More robust - it's just a value! So you can leverage the full strength of your type system to cover all cases and you can granularly test all edge cases as well (it's not just an InvalidInputException - it can be an InvalidEmailInput, InvalidAgeInput, etc along w whatever info you want to include).
- More composable - it's just a value! Because it plays nice with the type system and control flows you can build more robust blocks of logic that are explicit about what they do. This makes it much easier to compose these blocks together and be sure you're covering all cases.
- More performant - it's just a value! No need to take a copy of the stack trace (unless you want to) which is about ~300x faster than exceptions in TypeScript.
Yes some exceptional cases will exist even if you use errors as values but those will be limited to truly exceptional cases that can't / won't be handled. When you see an exception that is not exceptional or that can be handled, you now have a robust way of encoding that in your system so that your block and all blocks that use it can benefit from that knowledge.
Less footguns and more sturdy building blocks lead to more robust, composable, and performant systems.
Does this mean you should never use exceptions?
Some people will argue yes but I prefer a more pragmatic approach - it depends.
Exceptions are great for cases where you can't / won't recover. So if there's nothing you can do about it and you know none of your callers can do anything about it then throwing can be a much simpler thing to do. Just note that you are making that choice for all your callers and weigh the pros / cons.
This is akin to seeing a problem and panic
ing because you're not sure what to do about it. In some cases that's useful but in most it's not.
Errors as values should be used in basically every other case. This is because in most situations panic
ing is not the most rational decision. There's almost always a better way to handle a situation than that - even if you are not the one actually handling it. By encoding errors as values, you give your callers the option to handle the issue if they have a better way to, thus providing more control to your callers and making your building block that much more solid and useful.
Next
If you want to get started with errors as values, I highly recommend looking into Result<Success, Failure>
types. These are an ergonomic, universal method for representing a type that can have either a Success
or Failure
payload and IMO is strictly better than the BoolWithReason
(bool, string) that is prevalent in many langs like Go and Python because it is more flexible in the types it can hold and leverages the type system to ensure that on success it's always a Success
type and on failure it's always a Failure
type.
Once you're comfortable with that, there's a whole world of operations that make results even easier to work with including railroad programming, libraries for composing results, and additional types but you can worry about that stuff later - Results will get you far w minimal learning requirements.
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.