Why Types Matter
Why Types Matter
Python still runs without annotations. A type checker does not. Gradual typing means you add hints at the seams that hurt — public functions, None returns, dicts of ids — and leave the rest alone until a bug names them. The boring default is: annotate the function, run the program, let mypy, ty, or pyright complain in CI. Do not expect the runtime to enforce those comments.
This is the first chapter in the book that uses annotations on purpose.
Mental model
An annotation is a hint attached to a parameter, a return, or a variable:
def label(table: int, seats: int) -> str:
...CPython stores that hint. It does not type-check the call. label(12, "4") still runs. The string "4" is not an int; an f-string will still interpolate it.
A checker reads the hints and the call sites. mypy, ty (Astral’s checker), and pyright (the engine behind Pylance) all do this job. They catch:
- passing a
strwhere you declaredint - using a value that might be
Nonewithout a check - returning the wrong type
- treating a
dict[str, int]as if the keys wereint
They do not catch logic. seats + 1 on the wrong table still looks fine if both are int.
Gradual means mixed files are allowed. An unannotated function is Any from the checker’s point of view. That is why a fully untyped script is silent — and why a typed boundary next to an untyped helper leaks Any.
Worked examples
Case 1: a complete typed function
Save as table_label.py. Parameters and the return are annotated. main returns None because it only prints.
# table_label.py
def label(table: int, seats: int) -> str:
return f"table {table} seats {seats}"
def main() -> None:
print(label(12, 4))
if __name__ == "__main__":
main()Run:
uv run python table_label.pyOutput:
table 12 seats 4
The program is still ordinary Python. The hints document the contract a teammate (and a checker) can read.
Case 2: the bug a checker sees and the runtime does not
Save as seats_bug.py. raw is a string. label asked for int. Python concatenates it into the message anyway.
# seats_bug.py
def label(table: int, seats: int) -> str:
return f"table {table} seats {seats}"
def main() -> None:
raw = "4"
print(label(12, raw))
if __name__ == "__main__":
main()Run:
uv run python seats_bug.pyOutput:
table 12 seats 4
A checker reports that argument 2 is str, expected int. The printed line looks fine, so this is easy to ship. uv run python is not a type checker. Run mypy, ty, or pyright on the file when you care.
Fix: seats = int(raw) before the call, or give label a str if that is really the input.
Case 3: None is a type, not a vibe
Save as ticket_lookup.py. dict.get returns the value or None. The return type is int | None. announce wants an int. Check before you call.
# ticket_lookup.py
def find_table(tickets: dict[int, int], ticket_id: int) -> int | None:
return tickets.get(ticket_id)
def announce(table: int) -> str:
return f"now seating table {table}"
def main() -> None:
tickets = {7: 12, 8: 4}
table = find_table(tickets, 9)
if table is None:
print("ticket not on the board")
return
print(announce(table))
if __name__ == "__main__":
main()Run:
uv run python ticket_lookup.pyOutput:
ticket not on the board
If you skip the None check and pass table into announce, a checker flags it. At runtime you would print now seating table None — still a successful process, still wrong.
After if table is None: return, checkers narrow the type: table is int on the next line.
Case 4: annotations are not a runtime guard
Save as no_enforcement.py. Two strings go into a function that promised int. + concatenates.
# no_enforcement.py
def add_cents(a: int, b: int) -> int:
return a + b
def main() -> None:
print(add_cents("4", "50"))
if __name__ == "__main__":
main()Run:
uv run python no_enforcement.pyOutput:
450
That is "4" + "50", not 54 cents. Hints did not save the till. A checker would refuse the call. A test would refuse the result. The runtime smiled.
The trap
Annotating every local, every loop variable, and a 12-line script because a style guide said “100% coverage.” You spend the afternoon arguing with the checker about a print. Gradual typing is a budget: spend it on functions other people call and on values that can be None.
The other trap is treating a checker as optional forever. Unannotated code is not “simple.” It is unchecked. When the desk board grows a second module, add a checker to the same place you already run ruff and pytest.
Do not add a runtime type-validation library on day one to “make hints real.” Parse at the edge (int(raw)), keep the core as Python, and let the checker watch the core.
The boring rule
- Annotate public functions: parameters and return.
-> Noneformain. - Use
X | NonewheneverNoneis a real result. Check it before use. - Run mypy, ty, or pyright in CI once the project is more than one file. Pick one checker and keep it.
- Do not expect CPython to raise because a hint was wrong.
- Do not annotate everything. Annotate the contract.
- Built-in generics (
list[int],dict[int, int]) are enough to start. The next chapter fills in the rest.
Try this
- In
seats_bug.py, convertrawwithintbefore callinglabel. Confirm the program still prints the same line. - In
ticket_lookup.py, look up ticket7instead of9. Printannounce(table)after theNonecheck. - Change
add_centsto returna + bonly afterisinstancechecks — then delete those checks. Prefer a checker plusint()at the edge over defensiveisinstancein the core. - Point whichever checker you installed at
seats_bug.pyand read the error. Do not change the checker’s config yet; read the default.