Concepts / Modeling and API shape / Errors, exceptions and validation

Errors, exceptions and validation

Failing loudly at the boundary and never in the middle.

01The intuition

The problem it solves

A vending machine program returns strings to say what went wrong: "no such item", "not enough money", "sold out", and sometimes the item name on success. Callers compare strings, a typo in one place means an error is treated as a snack, and when a deep helper hits an unexpected case it returns None, which the caller passes along until, three functions later, something crashes with a message about None having no attribute. The real cause is nowhere near the crash.

def buy(self, code: str, paid: int):
    item = self._find(code)          # returns None if missing
    if item is None: return "no such item"
    if paid < item.price: return "not enough money"
    if item.stock == 0: return "sold out"
    self._dispense(item)             # returns None on jam, nobody checks
    return item.name

result = machine.buy("A7", 20)
if result == "not enugh money": ...   # typo: silently never true

Build it one piece at a time

Step one: give each failure a name as an exception type, and raise it where the problem is detected. Successful calls return the value; failures do not share the return channel.

class VendingError(Exception): ...
class UnknownItem(VendingError): ...
class SoldOut(VendingError): ...
class InsufficientFunds(VendingError):
    def __init__(self, needed: int, paid: int) -> None:
        super().__init__(f"need {needed}, paid {paid}")
        self.needed, self.paid = needed, paid

def buy(self, code: str, paid: int) -> "Item":
    item = self._find(code)                 # raises UnknownItem
    if item.stock == 0: raise SoldOut(code)
    if paid < item.price: raise InsufficientFunds(item.price, paid)
    self._dispense(item)                    # raises Jammed
    return item

Step two: validate at the boundary, once, and trust the inside. Inputs from a user or a network get checked where they enter; after that, the code assumes valid data and uses assertions only for bugs in its own logic.

def handle_button(self, raw_code: str, raw_paid: str) -> None:
    code = raw_code.strip().upper()
    if not CODE_PATTERN.match(code): raise ValueError("code looks like A1..F9")
    paid = int(raw_paid)                     # ValueError if not a number
    if paid < 0: raise ValueError("paid cannot be negative")
    self._machine.buy(code, paid)            # inside: no more re-checking

Step three: catch only where you can do something useful. The screen layer turns known errors into messages and lets unknown ones crash loudly, so a bug is found at its source instead of being hidden by a catch-all.

try:
    item = machine.buy(code, paid)
    screen.show(f"Enjoy your {item.name}")
except InsufficientFunds as e:
    screen.show(f"Insert {e.needed - e.paid} more")
except (UnknownItem, SoldOut) as e:
    screen.show(str(e))
# anything else propagates: a jam or a bug should be seen, not swallowed

Why it works

The principle: fail loudly at the edge, never quietly in the middle. Exceptions carry the failure to the one place that can react, with its cause attached, and the happy path reads without checks on every line. The invariant is that an object is never left half-changed by a failed call: validate first, then mutate, or undo on failure. Where it breaks: a bare except that hides everything, exceptions used for ordinary flow (looping until one is thrown), error types so general that callers cannot tell cases apart, and returning None to mean three different things. Interviewers test with bad input at every method and with "what state is the object in after this fails?"

The trade-off you must say out loud

A typed exception per failure is more classes, and some languages and teams prefer result objects (a value or an error, returned) for expected failures and exceptions only for bugs. Either is fine if you are consistent and say which. The follow-up is usually about partial failure in a multi-step operation, which leads to validation before mutation, or to compensating actions.

Where it shows up in the 100 Google problems

  • Vending machine: the example above; illegal events in the wrong state are also typed errors.
  • JSON parser: a parse error carries the line and column; the parser never returns a half-built tree.
  • Path-value file system API: creating a path whose parent is missing fails before anything is written.
  • Payment processor: retryable versus permanent failures are different types, and the caller's retry policy depends on it.
  • Streaming CSV importer: row errors are collected and reported, not raised, because one bad row must not stop the file.

02Used in these problems