Type Hints and What mypy Catches

Say what you meant, and let a tool check it

Advanced 11 min

In this lesson

A type hint records what a function expects and returns. Python ignores them when it runs, which sounds pointless until you realize that is the point: they exist for the people and tools reading your code. A checker like mypy uses them to find mistakes before you ever run the program.

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.

Example
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"))
Output
2.0
None
Arguments annotated with a colon, the return with an arrow.

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.

Example
def label(count: int) -> str:
    return f"{count} items"

print(label(3))
print(label("three"))   # wrong type, runs anyway
Output
3 items
three items
The hint says int. Python does not care.

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__)

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.

Example · report.py
def label(count: int) -> str:
    return f"{count} items"

total = label("three")
A file with one type error in it.
Example
$ 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)
mypy finds it without running the code.

Common mistake: Believing hints are enforced at runtime

Why it happens:

They look like the type declarations in languages that do enforce them.

How to fix it:

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

Why it happens:

Half-annotated code feels untidy.

How to fix it:

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

Why it happens:

It silences the error immediately.

How to fix it:

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

Why it happens:

The happy path returns a real value, so that is what gets written down.

How to fix it:

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?

What does str | None mean?

What is the main practical benefit of running mypy?

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}")

What to learn next

You annotated arguments and return values, met list[str], dict[str, int], and str | None, and saw for yourself that Python does not enforce any of it at runtime, which is exactly why hints are free to add and why a checker like mypy is what makes them pay.

Those hints have a natural partner. Dataclasses uses the same annotations to generate whole classes, and finally replaces the loose dicts and row tuples you have been carrying since Unit 7.