Why Types Matter

Updated

September 8, 2026

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 str where you declared int
  • using a value that might be None without a check
  • returning the wrong type
  • treating a dict[str, int] as if the keys were int

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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. -> None for main.
  • Use X | None whenever None is 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

  1. In seats_bug.py, convert raw with int before calling label. Confirm the program still prints the same line.
  2. In ticket_lookup.py, look up ticket 7 instead of 9. Print announce(table) after the None check.
  3. Change add_cents to return a + b only after isinstance checks — then delete those checks. Prefer a checker plus int() at the edge over defensive isinstance in the core.
  4. Point whichever checker you installed at seats_bug.py and read the error. Do not change the checker’s config yet; read the default.