Nested conditionals suck. They’re hard to write and even harder to read. I’ve rarely regretted the time I’ve spent optimizing for the flattest conditional structure in my code. The following piece mimics the actions of a traffic signal:
// src.ts
enum Signal {
YELLOW = "Yellow",
RED = "Red",
GREEN = "Green",
}
function processSignal(signal: Signal) :void {
if (signal === Signal.YELLOW) {
console.log("Slow down!");
} else {
if (signal === Signal.RED) {
console.log("Stop!");
} else {
if (signal === Signal.GREEN) {
console.log("Go!");
}
}
}
}
// Log
processSignal(Signal.YELLOW) // prints 'Slow down!'
processSignal(Signal.RED) // prints 'Stop!'
The snippet above suffers from two major issues:
- It contains three contiguous levels of nested conditionals.
- The conditionals don’t cover the case where the return value is
undefined
. - If you add a fourth member to the
Signal
enum, now the processing function doesn’t exhaustively cover all the cases and it won’t communicate that fact with you.
We can leverage guard clauses to fix the first two issues.
The guard (clause) provides an early exit from a subroutine, and is a commonly used deviation from structured programming, removing one level of nesting and resulting in flatter code: replacing
if guard { ... }
withif not guard: return
; …
We can rewrite the earlier snippet as follows:
// ...snip...
function processSignal(signal: Signal) {
if (signal === Signal.YELLOW) {
return "Slow down!";
}
if (signal === Signal.RED) {
return "Stop!";
}
if (signal === Signal.GREEN) {
return "Go!";
} else {
return "Not a valid input!";
}
}
This model has a flatter structure and now it’s gracefully handling the undefined
return
path. However, the third issue still persists. In an alien world, if someone added a fourth
member to the Signal
enum, that’d make the conditional flow in the processSignal
function incomplete since it wouldn’t be covering that newly added fourth enum member. In
that case, the above snippet will execute the final catch-all conditional statement; not
something that we’d want.
TypeScript provides a never
type to throw a compilation error if a new member isn’t
covered by the conditional flow. Here’s how you’d leverage it:
// src.ts
enum Signal {
YELLOW = "Yellow",
RED = "Red",
GREEN = "Green",
PURPLE = "Purple", // Newly added member.
}
function assertNever(value: never) {
throw Error(`Invalid value: ${value}`);
}
function processSignal(signal: Signal) {
if (signal === Signal.YELLOW) {
return "Slow down!";
}
if (signal === Signal.RED) {
return "Stop!";
}
if (signal === Signal.GREEN) {
return "Go!";
}
// Try commenting out this line and typescript compiler
// will throw an error.
if (signal === Signal.PURPLE) {
return "Go faster!";
}
assertNever(signal);
}
processSignal(Signal.PURPLE);
Ideally, the assertNever
should never be called. Try removing a conditional and see how
TypeScript starts screaming at you regarding the unhandled case. The assertNever
function
will also raise a runtime error if any case remains unhandled.
Example in Python
The same idea can be demonstrated in Python using Python3.10’s match
statement and
typing.NoReturn
type.
# src.py (Python 3.10+)
from __future__ import annotations
from enum import Enum
from typing import NoReturn
class Signal(str, Enum):
YELLOW = "Yellow"
RED = "Red"
GREEN = "Green"
PURPLE = "Purple"
def assert_never(value: NoReturn) -> NoReturn:
raise AssertionError(f"Invalid value: {value!r}")
def process_signal(signal: Signal) -> str:
match signal:
case Signal.YELLOW:
return "Slow down!"
case Signal.RED:
return "Stop!"
case Signal.GREEN:
return "Go!"
# Try commenting out this line and mypy will throw
# an error.
case Signal.PURPLE:
return "Go faster!"
case _:
assert_never(signal)
if __name__ == "__main__":
print(process_signal(Signal.PURPLE))
Similar to TypeScript, mypy will complain if you add a new member to the enum but forget to
handle that in the processor function. Python 3.11 added the Never
type and assert_never
function to the typing
module. Underneath, Never
is an alias to the NoReturn
type; so
you can use them interchangeably. However, in this case, Never
seems to communicate the
intent better. You may also choose to use the backported versions of the type and function
from the typing_extensions
module. Here’s how:
# src.py
from __future__ import annotations
import sys
from enum import Enum
if sys.version_info < (3, 11):
from typing_extensions import assert_never
else:
from typing import assert_never
...
Recent posts
- SSH saga
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing