Type Hints and What mypy Catches
Say what you meant, and let a tool check it
In this lesson
Explain it like I’m 5
A type hint is a label on the jar. Python will still let you put soup in the jar marked ‘flour’, but the label lets a checker warn you first.
Annotating a function
A hint goes after each parameter with a colon, and after the parameter list with an arrow for the return value. That is the whole syntax.
You will use a handful of notations constantly: list[str] for a list of strings, dict[str, int] for a dict mapping strings to integers, and str | None for something that might be missing, which is extremely common in real code.
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
def find_title(records: dict[str, str], key: str) -> str | None:
return records.get(key)
print(average([1.0, 2.0, 3.0]))
print(find_title({"a": "Dune"}, "b"))
2.0 None
Python does not enforce them
This surprises everyone once. Hints are not checked when your program runs. Pass the wrong type and Python does exactly what it always did.
That is a deliberate design decision, and it is why hints cost nothing at runtime and can be added gradually to code that already works. The checking is a separate step you run yourself.
def label(count: int) -> str:
return f"{count} items"
print(label(3))
print(label("three")) # wrong type, runs anyway
3 items three items
Add hints to average: it takes a list of floats and returns a float. The printed annotations show what Python stored.
def average(numbers): # TODO: annotate the argument and the return
return sum(numbers) / len(numbers)
print(average([1.0, 2.0, 3.0]))
print(average.__annotations__)
Write def average(numbers: list[float]) -> float:. Python keeps the annotations in __annotations__, which is how the printed dict appears.
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
print(average([1.0, 2.0, 3.0]))
print(average.__annotations__)
2.0
{'numbers': list[float], 'return': <class 'float'>}
Running the checker
The tool most people use is mypy, installed with pip install mypy and run over your files. It reads the annotations, follows the values through your code, and reports contradictions.
This is the payoff. The bug below is real, and mypy finds it without running the program, without a test, and without the specific input that would have triggered it.
def label(count: int) -> str:
return f"{count} items"
total = label("three")
$ mypy report.py
report.py:4: error: Argument 1 to "label" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Common mistake: Believing hints are enforced at runtime
They look like the type declarations in languages that do enforce them.
They are metadata. Run a checker such as mypy to get any checking at all, and keep validating real external input yourself.
Common mistake: Annotating everything at once on an existing project
Half-annotated code feels untidy.
Hints are designed to be added gradually. Start with the functions other code calls most; that is where the errors are most costly.
Common mistake: Reaching for Any whenever the checker complains
It silences the error immediately.
Any switches off checking for that value. Sometimes the complaint is the tool correctly telling you the type really is unclear.
Common mistake: Forgetting the | None on something that can be missing
The happy path returns a real value, so that is what gets written down.
If a function can return None, say str | None. That is precisely the case a checker can warn callers about.
What happens if you pass a str to a parameter annotated int?
Hints are metadata at runtime. Only a separate checker will object.
What does str | None mean?
It is a union: one type or the other. It does not by itself give the parameter a default.
What is the main practical benefit of running mypy?
It reads the annotations and reports contradictions statically, before the code executes.
Mini exercise (medium)
Annotate three functions: total(prices) taking a list of floats and returning a float; lookup(table, key) taking a dict of string to string plus a string key and returning str | None; and names(records) taking a list of dicts and returning a list of strings. Then say which of these calls mypy would reject: total([1, 2]), total("1,2"), lookup({}, 3), names([]).
Give it a shot. Complete the code and press Run; there’s nothing to download or configure.
def total(prices): # TODO: list of floats -> float
return sum(prices)
def lookup(table, key): # TODO: dict[str, str] and a str key -> str | None
return table.get(key)
def names(records): # TODO: list of dicts -> list of str
return [record["name"] for record in records]
# TODO: mark each call "accepted" or "rejected" by mypy
verdicts = {
"total([1, 2])": "?",
'total("1,2")': "?",
"lookup({}, 3)": "?",
"names([])": "?",
}
for call, verdict in verdicts.items():
print(f"{call}: {verdict}")
Write the annotations first, then check each call against them. Remember that mypy accepts an int where a float is expected, but not a str.
def total(prices: list[float]) -> float:
return sum(prices)
def lookup(table: dict[str, str], key: str) -> str | None:
return table.get(key)
def names(records: list[dict[str, str]]) -> list[str]:
return [record["name"] for record in records]
verdicts = {
"total([1, 2])": "accepted",
'total("1,2")': "rejected",
"lookup({}, 3)": "rejected",
"names([])": "accepted",
}
for call, verdict in verdicts.items():
print(f"{call}: {verdict}")
total([1, 2]): accepted
total("1,2"): rejected
lookup({}, 3): rejected
names([]): accepted
assert total.__annotations__, "total should be annotated"
assert lookup.__annotations__.get("key") is str, "the lookup key should be annotated str"
assert "return" in names.__annotations__, "names needs a return annotation"
print("✓ Looks good!")