Lesson 3: Readable Logic
Correct code that nobody can follow is a liability, because the next change to it will introduce a bug. This lesson covers three specific habits that make logic readable, all of which appear repeatedly in the code you will write for the rest of the course.
Guard clauses and early returns
Deeply nested code is hard to read because understanding any line requires holding every enclosing condition in your head at once.
def process_document(path: Path) -> Document | None:
if path.exists():
if path.suffix in {".txt", ".html"}:
content = path.read_text(encoding="utf-8", errors="replace")
if len(content.strip()) >= MIN_CONTENT_LENGTH:
if not is_duplicate(content):
return build_document(path, content)
else:
return None
else:
return None
else:
return None
else:
return None
To understand the return build_document(...) line you must track four conditions simultaneously. The four else: return None branches are identical but separated, and adding a fifth check means another level of indentation.
A guard clause inverts each condition and exits immediately.
def process_document(path: Path) -> Document | None:
if not path.exists():
return None
if path.suffix not in {".txt", ".html"}:
return None
content = path.read_text(encoding="utf-8", errors="replace")
if len(content.strip()) < MIN_CONTENT_LENGTH:
return None
if is_duplicate(content):
return None
return build_document(path, content)
Same behaviour, and much easier to follow. Each condition is handled and dismissed. By the time execution reaches the final line, every requirement has already been satisfied, and you do not need to remember any of them.
The pattern reads as: eliminate the ways this can fail, one at a time, then do the work. Adding a fifth check adds two lines at the same indentation level rather than another nesting level.
Flat code beats nested code. The continue statements in Lesson 1's pseudocode are the loop version of the same idea. Both keep the main path of the function at the leftmost indentation, where it is easiest to see.
[IMAGE PROMPT M2-3
Purpose: Show visually how guard clauses flatten nested logic and keep the main path at a single indentation level.
Visual type: Before-and-after structural comparison using indentation shape.
Prompt: A clean educational side-by-side comparison showing code structure as abstract indented blocks rather than readable code. The left panel is headed "Nested" and shows a staircase of five progressively indented bars descending to the right, with the deepest bar labelled "the actual work" and small bars branching off to the left at each level, each labelled "return None". A bracket on the left is labelled "4 conditions to hold in mind". The right panel is headed "Guard clauses" and shows four short bars all at the same left-aligned indentation, each labelled "check, return None", followed by a final bar at the same indentation labelled "the actual work". A bracket on the right is labelled "0 conditions to hold in mind".
Required elements: A descending staircase shape on the left contrasted with a flat left-aligned stack on the right, the actual work labelled in both, branch bars on the left, brackets with the condition-count labels.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, abstract bars rather than legible code text.
Layout: Two equal panels side by side separated by a thin vertical divider, both reading top to bottom.
Text labels: "Nested", "Guard clauses", "the actual work", "return None", "check, return None", "4 conditions to hold in mind", "0 conditions to hold in mind".
Aspect ratio: 16:9
Accessibility: Convey the difference through bar position and explicit labels rather than colour alone.
Avoid: Real code screenshots, syntax highlighting, tiny text, logos, watermarks, decoration.
Alt text: Structural comparison showing nested conditionals forming a descending staircase with the real work buried at the deepest level, against guard clauses forming a flat left-aligned stack with the work at the end.
END IMAGE PROMPT]
Truthiness traps
Python treats several values as false in a boolean context: False, None, 0, 0.0, "", [], {}, set(), and (). This is convenient and it is also the source of a specific bug that is easy to write and hard to spot.
def summarize(results: list[str] | None) -> str:
if not results:
return "No results were returned."
return f"Found {len(results)} results."
if not results is true when results is None and also true when results is an empty list. Those are different situations. None usually means the operation never ran or failed. An empty list means the operation ran and found nothing. Collapsing them hides the distinction, and if the caller needed to know which occurred, the information is gone.
Be explicit about which you mean:
if results is None:
return "Search did not run."
if len(results) == 0:
return "Search ran and found nothing."
return f"Found {len(results)} results."
The trap gets worse with numbers.
def apply_timeout(timeout: float | None) -> float:
if not timeout: # bug
return DEFAULT_TIMEOUT
return timeout
0 is falsy, so a caller who explicitly passes 0 to mean "do not wait" silently gets the default instead. The same bug appears with if not count, if not score, and if not offset. Any time zero or an empty string is a meaningful value, truthiness is the wrong test.
if timeout is None:
return DEFAULT_TIMEOUT
return timeout
The rule. Use truthiness when "empty or absent" genuinely means the same thing and you only care whether there is anything to work with. Use is None when absence is meaningfully different from emptiness or zero. When unsure, is None is the safer default, because being explicit never causes a bug and being implicit sometimes does.
Note that is None uses identity comparison rather than ==. None is a single object, so is is both correct and faster, and it cannot be fooled by a class that defines an unusual __eq__.
Predict the output.
def describe(items):
if not items:
return "empty"
return f"{len(items)} items"
print(describe([]))
print(describe(None))
print(describe([0]))
print(describe(0))
Answer
empty
empty
1 items
empty
The first two are indistinguishable despite meaning different things. The third works because a list containing zero is itself non-empty, which catches people out. The fourth is arguably a bug in the caller, passing an integer where a collection was expected, and truthiness hides it by returning a plausible-looking answer instead of raising.
match/case for dispatching on message and tool types
Introduced in Python 3.10, match/case performs structural pattern matching, which checks the shape of a value rather than only its equality. It is a genuine improvement over a chain of if/elif when dispatching on the type or shape of a payload, which is exactly what you do constantly when handling model responses.
Consider handling the content blocks from Lesson 2. Written with conditionals:
def handle_block(block: dict) -> str:
if block.get("type") == "text":
return block["text"]
elif block.get("type") == "tool_use":
return f"Calling {block['name']} with {block['input']}"
elif block.get("type") == "image":
return f"[image: {block.get('source', {}).get('media_type', 'unknown')}]"
else:
return f"[unsupported block type: {block.get('type')}]"
This works, but every branch repeats block.get("type"), and each one independently reaches into the dict for fields whose presence it is assuming.
With match/case:
def handle_block(block: dict) -> str:
match block:
case {"type": "text", "text": str(text)}:
return text
case {"type": "tool_use", "name": str(name), "input": dict(args)}:
return f"Calling {name} with {args}"
case {"type": "image", "source": {"media_type": str(media_type)}}:
return f"[image: {media_type}]"
case {"type": str(unknown)}:
return f"[unsupported block type: {unknown}]"
case _:
return "[malformed block]"
Several things happen here that the conditional version could not do concisely.
Matching and extracting in one step. {"type": "text", "text": str(text)} checks that the dict has a type key equal to "text", has a text key whose value is a string, and binds that value to the name text. Three operations, one line.
Type checking inside the pattern. str(name) does not call str(). In a pattern it means "matches if this value is a string". A tool call whose name arrived as a number will not match, and will fall through instead of failing later.
Nested shape matching. The image case reaches two levels down and matches only if the whole shape is present, replacing the chained .get() calls from Lesson 2.
Ordered fallbacks. Cases are tried top to bottom. The second-to-last catches any block with a string type that no earlier case handled. The final case _ is a wildcard matching anything, so malformed input has a defined outcome rather than falling off the end.
Important detail about dict patterns. They match on the keys named and ignore any others. A block containing type, text, and fifteen extra fields still matches {"type": "text", "text": str(text)}. This is usually what you want with API responses, since providers add fields over time and you do not want new fields to break your handler.
When not to use it. For a simple equality check against two or three values, if/elif is shorter and clearer. match earns its place when you are matching on shape, extracting values while matching, or handling more than about four cases. Reaching for it everywhere is as much a habit as reaching for a list.