Skip to main content

Error handling

Every program has to deal with things going wrong: bad input, a network call that times out, a file that isn't there. None of that is optional. What's optional is whether the error handling swallows the code around it. A function whose real logic is three lines wrapped in four levels of if checks has become, in practice, a function about error handling that happens to do something useful in the middle. This chapter is about keeping the two concerns separate so each can be read on its own.

Exceptions over return codes

The oldest way to report failure is a return code or a status flag, checked by the caller immediately after the call. It works, but it's opt-in: nothing stops a caller from forgetting the check, and the check itself competes for attention with the actual logic. Once a codebase has a few of these nested three deep, you stop being able to see what the function does at a glance.

Throwing an exception instead of returning a code splits the two concerns physically. The happy path reads like the algorithm it is, and the failure path lives in a catch block that doesn't interrupt it.

class DeviceController:
def send_shut_down(self):
handle = self.get_handle(DEV1)
if handle != INVALID_HANDLE:
record = self.retrieve_device_record(handle)
if record.status != SUSPENDED:
self.pause_device(handle)
self.clear_work_queue(handle)
self.close_device(handle)
else:
self.logger.log("Device suspended, cannot shut down")
else:
self.logger.log("Invalid handle for DEV1")
 
// The shutdown steps are three lines. Getting to them
// takes two nested error checks first.
class DeviceController:
def send_shut_down(self):
try:
self.try_to_shut_down()
except DeviceShutDownError as e:
self.logger.log(e)
 
def try_to_shut_down(self):
handle = self.get_handle(DEV1)
record = self.retrieve_device_record(handle)
self.pause_device(handle)
self.clear_work_queue(handle)
self.close_device(handle)
 
def get_handle(self, device_id):
# raises DeviceShutDownError if the handle is invalid
...
 
// Two concerns - what shutdown does, and what happens
// when it fails - are no longer tangled into one method.

Write the try/catch/finally first

A try block is a promise: execution here can stop at any point, and whatever runs in catch has to leave the program in a sane state regardless of where it stopped. That's a strong enough guarantee that it's worth pinning down before writing the code inside the block, not after.

In practice this means: when you're about to write code that can fail, write the try/catch/finally shell first, with a test that forces the exception to fire. Get that red-then-green before filling in the body. You end up building the transactional boundary first and the logic inside it second, instead of retrofitting error handling onto code that was never written with a scope in mind.

Use unchecked exceptions

Some languages (Java, notably) let you distinguish "checked" exceptions - ones a method must declare it throws - from unchecked ones. Checked exceptions sound appealing: the compiler forces every caller to acknowledge a failure mode. In practice, the cost usually outweighs the benefit.

The problem is signature pollution. If a low-level function three call-levels down starts throwing a new checked exception, every function between it and the eventual handler has to add that exception to its own signature, or catch and rethrow it. A change nobody outside that one function cares about ripples upward through the whole call chain. That's exactly the opposite of what encapsulation is supposed to buy you. Languages that skip checked exceptions entirely (Python, Ruby, C#) don't seem to suffer for it - robust software doesn't require the compiler to enforce error acknowledgment at every layer.

Give exceptions context

A stack trace tells you where an exception was thrown. It does not tell you what the code was trying to do when it failed. Write exception messages that state the operation and the kind of failure, and pass enough information that whatever logs the exception can produce a useful line. "File not found" is nothing; "failed to load user preferences from config.json" is something you can act on at 3am.

Design exception types around the catcher

You can classify exceptions by where they came from or what kind of failure they represent, but the classification that actually matters is: how does the calling code want to catch them? If three exception types from a third-party API all get handled identically - log it, report it, move on - then having three catch clauses is pure duplication with no upside.

The fix is a wrapper. Instead of calling the third-party API directly and catching its whole menagerie of exception types, wrap it in your own class that catches all of them internally and re-throws one exception type that means something to your application. This has a second benefit beyond removing duplication: wrapping third-party APIs is generally good practice. It means you depend on an interface you control, you can swap the underlying library later without touching every call site, and it's much easier to fake out the wrapped call in tests. One exception type is usually enough for a whole area of code - split into more than one only when the caller genuinely needs to handle two failure modes differently, not because they came from different underlying causes.

DeviceController+ sendShutDown()CALLERLocalPort- innerPort: ACMEPort+ open()+ close()WRAPPERACMEPort+ open()+ close()THIRD-PARTYPortDeviceFailureYOUR
dependencycomposition
LocalPort absorbs ACMEPort's whole exception menagerie so DeviceController never has to.

Define the normal flow

Pushing error handling to the edges is usually the right move, but taken too literally it turns ordinary business rules into exceptions. Consider a billing routine that sums an employee's meal expenses, falling back to a flat per-diem when there's no expense record for that day. Written as "get the expenses, catch the not-found exception, use the per-diem instead," the exception handling is standing in for a case that isn't actually exceptional - it's just one of two normal outcomes.

The cleaner move is Martin Fowler's Special Case pattern: make the "no expenses" case return an object that behaves like a real one, reporting the per-diem as its total. The calling code then has nothing special to handle - it just asks for the total and adds it up, because every path now returns something that answers that question honestly.

Don't return null

Returning null looks harmless and turns into the single most common source of runtime crashes. Once one function in a call chain can return null, every caller either has to check for it or risk a crash three functions away from where the None/null originated - and it's easy to forget exactly one of those checks.

The better options: throw an exception if null genuinely means failure, or return a Special Case object (an empty list instead of null, for instance) if the absence of a value is a normal outcome. return [] instead of return None turns "check for null before the loop" into "just loop" - the empty list already behaves correctly with zero iterations.

Don't pass null

If returning null is bad, passing it in as an argument is worse, because there's rarely anything sensible a function can do when an argument it needed turns out to be missing. Assertions document the expectation but don't prevent the crash. A custom "invalid argument" exception forces every caller to decide how to handle a failure that's really a programming mistake, not a runtime condition.

The pragmatic default: don't design APIs that accept null as a real, meaningful input, and don't let your own code pass it around. Treat a None/null argument turning up somewhere in the middle of your code as a bug to fix, not a case to handle gracefully.