Coding by Hand
Python home

Types and Type Hints

Every plate in a real gym is painted. A 45-pound plate is red. A 35 is blue. A 25 is green. A 10 is white. The paint is not for decoration. The paint is so the kid loading the bar at 6 a.m. cannot put a 25 where a 45 should go and snap his back. A type hint is the paint. You write def score_hand(cards: list[Card]) -> int and the paint is on the function: this slot only takes a list of Cards, and what comes out is an integer. The Python interpreter does not actually check the paint. The reader checks it. A separate program called a type checker checks it. The compiler that ships inside your editor checks it. The painted bar is what catches the bad load before the lift.

Python lived for 23 years with no painted plates at all. The first version shipped in 1991. You could write def add(a, b) and pass it two strings, two integers, a list and a dictionary, anything — the function would just try to run, and if a + b blew up, that was your problem at runtime. In 2014 a Finnish researcher named Jukka Lehtosalo built a tool called Mypy, originally as his PhD thesis at the University of Cambridge, that read Python source code and checked the types as if Python had them. Guido van Rossum took the idea seriously and wrote PEP 484 in 2015, which added type hint syntax to Python itself. The hints sat in the language but did nothing at runtime. Microsoft saw the opening and shipped pyright in 2019, a type checker written in TypeScript that powers Pylance, the language server inside VS Code. By 2023 PEP 695 had added cleaner generic syntax in Python 3.12. The painted-plate era was complete: every modern Python codebase has hints, and at least one type checker scans them.

A function signature painted like a gym plate, every part labeled.
A function signature painted like a gym plate, every part labeled.

A type hint has three jobs. It documents the function for the next reader. It lights up your editor with autocomplete and red squiggles when you misuse the function. It catches a class of bug — passing the wrong shape of data — before the program runs. The cost of writing the hint is one line per function. The payoff is the rest of your project.

The simple shapes you have already seen in the last two lessons. int, str, bool, float, bytes are the primitives. list[int] is a list of integers. dict[str, int] is a dictionary mapping strings to integers. tuple[str, int, int] is a fixed-length tuple of three pieces. Card | None (in Python 3.10 and later) means a Card or nothing. The older syntax wrote that as Optional[Card] from the typing module. Same meaning, newer punctuation. Open a file called score_typed.py and try this:

from cards import Card
 
 
def best_card(cards: list[Card]) -> Card | None:
    if not cards:
        return None
    face_order = {"A": 14, "K": 13, "Q": 12, "J": 11}
    return max(cards, key=lambda c: face_order.get(c.rank, int(c.rank)))
 
 
hand = [Card("9", "s"), Card("K", "h"), Card("4", "c")]
print(best_card(hand))
print(best_card([]))

Output:

Kh
None

The hint says: hand me a list of Cards, I will give you back either a Card or None. The reader of the function does not have to read the body to learn that. The hint is a contract.

Now you need a referee that actually checks the contract. Install Mypy into your venv:

pip install mypy
pip install mypy

Write a file with a deliberate bug in it. Save this as bad_score.py:

from cards import Card
 
 
def score_hand(cards: list[Card]) -> int:
    counts: dict[str, int] = {}
    for c in cards:
        counts[c.rank] = counts.get(c.rank, 0) + 1
    multiples = sorted(counts.values(), reverse=True)
    if multiples[0] == 2:
        return "one pair"
    return 0
 
 
print(score_hand([Card("A", "s"), Card("A", "h")]))

The function signature promises an int. The body returns "one pair" on one branch. Python itself does not care — it would happily run this until the caller did math on the result. Mypy does care. Run it:

mypy --strict bad_score.py

Output:

bad_score.py:10: error: Incompatible return value type (got "str", expected "int")  [return-value]
Found 1 error in 1 file (checked 1 source file)

The error tells you the file, the line number, and the exact mismatch. The bug is caught before the code ever runs. Fix the line to return 100 and run mypy again — silent. The contract is honored. This is the loop you want for every Python file you write from here on out: write the hints, run the checker, fix the mismatches, ship the file.

A small question for the reader: in the error above, what does the [return-value] tag at the end of the message mean?

That tag is the rule name. Every error mypy raises has a category. [return-value] means the function returned the wrong type. [arg-type] means a caller passed the wrong type. [assignment] means a variable was bound to the wrong type. The rules are listed in mypy's docs, and you can suppress one rule for one line with a comment like # type: ignore[return-value] if you have a real reason. Most of the time you should fix the bug instead of silencing the rule.

Editor to language server to red squiggle: how mypy and pyright catch the bad load.
Editor to language server to red squiggle: how mypy and pyright catch the bad load.

A few more pieces complete the toolkit. Literal lets you say a value must be one of a fixed set of strings or numbers. TypedDict describes a dictionary that has known keys, useful when you parse JSON. Protocol is the Pythonic way to say "anything that has these methods" — duck typing with teeth. The Protocol example is the one worth typing. You taught your Deck how to shuffle. Other things in the world also know how to shuffle: a deck of Pinochle cards, a list of songs in a playlist, a tournament bracket. None of them inherit from each other. They all just happen to expose a shuffle() method. A Protocol lets you write a function that accepts any of them.

from typing import Protocol
 
 
class Shufflable(Protocol):
    def shuffle(self) -> None: ...
 
 
def warm_up(thing: Shufflable, times: int) -> None:
    for i in range(times):
        thing.shuffle()
        print(f"warmup {i + 1} done")

Any object that exposes a shuffle() method satisfies Shufflable, with no inheritance and no base class. Mypy checks this for you when you call warm_up(deck, 3) — if deck does not have a method called shuffle that takes no arguments and returns nothing, the call is flagged. You wrote a contract that any future deck-like thing can fulfill without knowing about your code at all. That is the same trick that lets len() work on a list, a dict, a string, and your own Deck class — every one of those exposes a __len__ method, and len() only requires that method to exist.

The last move is to make the checker run on every file you have, every commit. The simplest way is to add a file called pyproject.toml to your project root with this block:

[tool.mypy]
strict = true

Now mypy . checks the whole project at strict settings, the same level professional Python codebases use. Strict mode demands a return type on every function, no untyped functions allowed, no implicit Any types — the painted plates become mandatory. You will run into errors at first. Each one is a real piece of fuzziness in your code that becomes a real bug six months from now. Fix them as they appear.

Types catch a wide class of bugs. They cannot save you from logic errors, race conditions, or the handful of edge cases Python has where the language behaves in a way no static checker can predict. Those are the gotchas, and the next lesson walks through every one that has bitten you in your sleep.