whether to design your software to emit exceptions is neither a best practice nor an anti-pattern. — the immortal wisdom of RayLuo (link)
The above is the best answer I've seen. My answer is intended to supplement RayLuo's with insights not yet covered; none of it is based in things I've seen in the wild - these are just thoughts I've been synthesizing over the years that I want to explore and share. Thanks go to RayLuo for encouraging me to add my thoughts! ♥️
The perversity of the Universe tends towards a maximum.
— Finagle's law
In programming, we often like to focus on the cases where things go right. When we're designing a new thing, we're asserting something that didn't exist before. It's natural to focus on what that thing is, and not in the uncountably many ways that it can go wrong.
It's also expensive to even think of all those ways that a system can fail, not to mention to encode a description of how to handle those cases when they arise. Our jobs are hard enough as it is, just describing this thing that didn't exist before!
As programmers, we attempt to optimize our code towards a description that matches what we want, and have it take effect by enscribing this description into reality. But reality is a messy place.
The geometry of failure
If you look at most of the access patterns of our programs, they probably look like
< 1% errors (hopefully?)
but in terms of possible success and failure states, it's probably
>99% errors and
< 1% success
through this lens, the success case looks quite exceptional.
Developers don't always handle failure modes...
Let's say you import a module that you didn't write yourself, and your code execution encounters a
KeyError from within the foreign module. It doesn't say what it means; it's just a generic
KeyError. It could be because of how your code interacted with it, it could be from a misconfigured dependency or expectation of the foreign module, it could be anything.
You write a
try/except and start trying to figure things out, but then there's different areas where the
KeyError might be coming from, and it could mean something completely different.
...Or success modes...
Now let's look at a different scenario: you import a module that you didn't write yourself, and your code execution encounters a
ValueFoundInDatabase exception. Odd. Usually calling a function results in a return value. You need this module though, so you write a
try/except for it.
Then you encounter a
ValueFoundInCache exception. Huh. You dive deeper and you see that both of these exceptions are sub-classes of
SuccessState, which eventually subclasses
Exception. You start to trace the trees of exceptions, which is really just a tree of different states the program might terminate in, including a lot of exceptions that derive from
You've already written a
try/except, and handling the failure states seems easily within reach; it's another
except, just like handling the success states, and its implementation merely depends on what precision your application's current form could care to handle:
FailureState in general or more specifically:
Suddenly you know you can handle specific edge cases with not much extra work. You don't have to think hard about what they are or how to detect them because the module author already put thought and care into it. Additionally, there's a few extra 'edge'
SuccessStates that you can handle with additional flair, because you have easy access to the specific descendants of
SuccessState. If you know a successful result came from the cache or the database, you can flow your control accordingly.
Because you're already handling the results with a
try/except to handle the
SuccessState or its descendants, it's so easy to just add another
except for whatever level of other states you prefer to address. That module developer may have wanted to
raise your awareness to these other states, and have you handle those edge cases instead of writing a function call and going on your merry way.
The result is also: completely awful for quickly writing functioning code, because you need to
try/except anything that has this functionality, and you can't tell which "color" it is in the function coloring sense. In my eyes, it's ugly to look at. But textual code-writing is ugly to look at.
Another form of the above is to (gasp) return an object that has more information about the state the process terminated in. The object itself could be of different types that represent these states, or more crudely it could have a property called
state that taps into something like those exception trees from before (side note:
exceptions don't need to be raised, they're just another object).
Long blast radius
There's also the side-benefit/drawback of RayLuo's mention of a "long blast radius". A calling function scope might not be the one to handle the resulting
exception; it could skip several layers, which could be a feature in, say, a function in a web server that wants to immediately
raise some kind of
Http_Response as an exception. This approach is, I suspect, completely terrible. Definitely different than what we're used to. But it might be a feature in the high-flying
duck.quack() EAFP example.
Enumeration of states
Sandi Metz has been a proponent of condition-less coding, and I can see why: every if/else is a branching of program state -- and often that results in multiplicative, or even exponential effects. This isn't exactly the same as what I'm showcasing here, but it rhymes.
I'm not saying exceptions are the answer, but I must admit: it's nice to dream of ways to enumerate and integrate all of the relevant states a program is designed to terminate in. This includes:
- variants of success states
- things went great "and by the way" states
- more nuanced "something went wrong, but it's kinda ok?" in-between states
- the huge tree of failure states
return value feels a little too simple.