Skip to content
aviral gupta

// A2.1 · ~30 min · Advanced

Type hints and a type checker

After this lesson you can annotate functions and variables with list[int], dict[str, float] and X | None, run mypy and read what it reports, and explain why Python itself never checks a hint.

Lesson 1 of 6 in A2 Typing

Start of the module

You will be able to

  • Annotate parameters, return values and variables with built-in generics and X | None
  • Run mypy on a file and read its messages and error codes
  • Predict what hints do at runtime: they are stored, never checked
  1. Warm-up · Activity 1 of 7

    Warm-up from the intermediate level: in def area(width: float, height: float) -> float:, what is -> float?

  2. Predict · Activity 2 of 7

    Predict before you read on: n is annotated as int. What does this print?

    def double(n: int) -> int:
        return n * 2
    
    
    print(double("ab"))
  3. Practice · Activity 3 of 7

    first() returns the first item, or None for an empty list. Fill in the operator so the return hint says "an int or None".

    def first(items: list[int]) -> int ____ None:
        return items[0] if items else None
    int None:
  4. Practice · Activity 4 of 7

    mypy reports two errors on line 4 of this file. Which error code does it put in brackets at the end of both?

    def total(prices: list[float]) -> float:
        return sum(prices)
    
    
    print(total(["3.50", "1.20"]))
  5. Practice · Activity 5 of 7

    Match each annotation to a value it describes.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. You run python -m mypy main.py, with no options, on this file. What is the last line mypy prints?

    def load():
        count: int = "three"
        return count
  7. Apply · Activity 7 of 7

    Mini-task, on your own Python with mypy installed. Write parse_price(text), which returns the float in text or None when text is not a number, and total(prices), which adds a list of such results and skips the None values. Annotate both, then run python -m mypy --strict on the file until it reports no issues.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Hints stored, not checked

Two annotated functions and an annotated variable. The program prints the stored hints, then calls mean() with a tuple of ints, which its hint does not allow. Python runs the call anyway. Without the # type: ignore[arg-type] comment, mypy reports that line: error: Argument 1 to "mean" has incompatible type "tuple[int, int, int]"; expected "list[float]" [arg-type].

main.py

# Hints describe types; Python stores them but never checks them.
def mean(values: list[float]) -> float:
    return sum(values) / len(values)


def find(stock: dict[str, int], name: str) -> int | None:
    return stock.get(name)


stock: dict[str, int] = {"apples": 4, "pears": 0}

print(mean.__annotations__)
print(find.__annotations__["return"])
print(mean([1.5, 2.5]))
# A tuple of ints: mypy objects, and this comment silences that error.
print(mean((1, 2, 3)))  # type: ignore[arg-type]
print(find(stock, "plums"))

Run it with

python main.py

Output

{'values': list[float], 'return': <class 'float'>}
int | None
2.0
2.0
None
  • __annotations__ holds the hints as objects: list[float] is a generic alias, float is the class itself.
  • int | None is a union object; it prints the way you wrote it.
  • mean((1, 2, 3)) works, because sum() and len() accept a tuple. Only mypy objects, and the comment silences exactly that error code.
  • find() returns None for a missing name, which is what the | None in its hint promises.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Annotate the price helpers

The three functions work, but have no hints. Annotate them: parse_price takes a str and returns a float or None; cheapest takes a list of floats and returns a float or None; label takes a name (str) and a price that may be None, and returns a str. The tests read the hints with typing.get_type_hints, so write them exactly as described.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    The pattern is def name(param: Type) -> ReturnType:. A result that may be missing is float | None.

  2. Hint 2

    A list of floats is list[float]; no import is needed for list or for the | operator.

  3. Hint 3

    label(name: str, price: float | None) -> str: the price parameter may be None, so its hint says so.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def parse_price(text: str) -> float | None:
    try:
        return float(text)
    except ValueError:
        return None


def cheapest(prices: list[float]) -> float | None:
    if not prices:
        return None
    return min(prices)


def label(name: str, price: float | None) -> str:
    if price is None:
        return f"{name}: no price"
    return f"{name}: {price:.2f} EUR"
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def parse_price(text):
    try:
        return float(text)
    except ValueError:
        return None


def cheapest(prices):
    if not prices:
        return None
    return min(prices)


def label(name, price):
    if price is None:
        return f"{name}: no price"
    return f"{name}: {price:.2f} EUR"

test_main.py

from typing import get_type_hints

from main import cheapest, label, parse_price


def test_parse_price_hints():
    """parse_price takes a str and returns float | None"""
    got = get_type_hints(parse_price)
    assert got == {"text": str, "return": float | None}, f"the hints of parse_price are {got!r}"


def test_cheapest_hints():
    """cheapest takes list[float] and returns float | None"""
    got = get_type_hints(cheapest)
    assert got == {"prices": list[float], "return": float | None}, f"the hints of cheapest are {got!r}"


def test_label_hints():
    """label takes a str and a float | None, and returns a str"""
    got = get_type_hints(label)
    assert got == {"name": str, "price": float | None, "return": str}, f"the hints of label are {got!r}"


def test_behaviour():
    """The functions still work: 2.5, None, 1.25 and the two labels"""
    got = parse_price("2.5"), parse_price("free"), cheapest([2.5, 1.25]), cheapest([])
    assert got == (2.5, None, 1.25, None), f"the functions returned {got!r}"
    got_labels = label("tea", 2.5), label("cake", None)
    assert got_labels == ("tea: 2.50 EUR", "cake: no price"), f"label returned {got_labels!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Make mypy clean

Run this one on your own Python: install mypy (python -m pip install mypy), then run python -m mypy --check-untyped-defs main.py. It reports two errors, and the first is a real bug: read_scores keeps the points as strings. Fix both so mypy reports no issues and the tests pass. Change the code where the hint is right, and the hint where the code is.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    mypy infers scores = {} from the first value you store in it, so it sees dict[str, str] and says the return value does not match.

  2. Hint 2

    The hint dict[str, int] is right; convert the points with int(points).

  3. Hint 3

    Dividing with / always gives a float, so the return hint of average is the part to change: -> float.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def read_scores(lines: list[str]) -> dict[str, int]:
    scores: dict[str, int] = {}
    for line in lines:
        name, points = line.split(",")
        scores[name] = int(points)
    return scores


def average(scores: dict[str, int]) -> float:
    return sum(scores.values()) / len(scores)


def best(scores: dict[str, int]) -> str | None:
    if not scores:
        return None
    return max(scores, key=lambda name: scores[name])
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def read_scores(lines: list[str]) -> dict[str, int]:
    scores = {}
    for line in lines:
        name, points = line.split(",")
        scores[name] = points
    return scores


def average(scores: dict[str, int]) -> int:
    return sum(scores.values()) / len(scores)


def best(scores: dict[str, int]) -> str | None:
    if not scores:
        return None
    return max(scores, key=lambda name: scores[name])

test_main.py

from main import average, best, read_scores


def test_mypy_is_clean():
    """mypy --check-untyped-defs reports no errors for main.py"""
    from mypy import api

    report, errors, status = api.run(["--check-untyped-defs", "--no-error-summary", "main.py"])
    assert status == 0, f"mypy found problems:\n{report}{errors}"


def test_read_scores():
    """read_scores turns "ada,3" into {"ada": 3}"""
    got = read_scores(["ada,3", "bob,5"])
    assert got == {"ada": 3, "bob": 5}, f"read_scores returned {got!r}, expected ints as values"


def test_average():
    """The average of 3 and 5 is 4.0"""
    got = average({"ada": 3, "bob": 5})
    assert got == 4.0, f"average returned {got!r}, expected 4.0"


def test_best():
    """best names the highest score, and returns None for no scores"""
    got = best({"ada": 3, "bob": 5}), best({})
    assert got == ("bob", None), f"best returned {got!r}, expected ('bob', None)"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Expecting the hint to check the argument

def mean(values: list[float]) -> float:
    return sum(values) / len(values)


print(mean(["1.5", "2.5"]))

What Python prints

TypeError: unsupported operand type(s) for +: 'int' and 'str'

Why, and the fix

The hint list[float] did not stop the strings: Python ran the call, and sum() failed inside the function, far from the real mistake. Hints are checked only by a type checker. Run python -m mypy main.py, which reports List item 0 has incompatible type "str"; expected "float" on the call line, and convert the input where it enters the program.

Using an X | None result as if it were an X

def find(names: dict[str, str], key: str) -> str | None:
    return names.get(key)


print(find({"ada": "Ada Lovelace"}, "bob").upper())

What Python prints

AttributeError: 'NoneType' object has no attribute 'upper'

Why, and the fix

str | None promises that the result may be None, so the caller must deal with that before calling a str method. mypy reports it without running anything: Item "None" of "str | None" has no attribute "upper" [union-attr]. Test first, if name is not None:, or give a default with names.get(key, "").

Thinking a variable hint converts the value

count: int = "3"
print(count + 1)

What Python prints

TypeError: can only concatenate str (not "int") to str

Why, and the fix

An annotation on a variable converts nothing: count still holds the string "3". Convert explicitly, count: int = int("3"). mypy flags the original line: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment].

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Hints describe the types you mean

A hint follows a colon on a parameter or variable and an arrow on the return value: def mean(values: list[float]) -> float:. Built-in collections take their item types in square brackets: list[int], dict[str, float], set[str], tuple[str, int] for exactly two items and tuple[int, ...] for any number. X | Y means either type, and X | None is the usual way to say "may be missing". typing.Optional[X] and Union[X, Y] mean the same and are equal at runtime, but the | spelling needs no import.

mypy reads the code, Python runs it

mypy is a separate program: install it with python -m pip install mypy and run python -m mypy main.py. It never runs your code; it reads it and reports lines such as main.py:4: error: Argument 1 to "add" has incompatible type "float"; expected "int" [arg-type]: file, line, message and an error code in brackets. A clean file ends with Success: no issues found. By default mypy skips the body of a function that has no annotations at all; --check-untyped-defs, which this course uses, checks those too.

At runtime, hints are only data

The interpreter stores hints in the function's __annotations__ and never looks at them when it calls the function. double("ab") with n: int runs and returns "abab"; count: int = "3" leaves a string in count. A wrong type is noticed only when an operation fails, or not at all. That is the division of labour: mypy, your editor and your readers use the hints before the program runs, and Python ignores them while it runs.

Sources

Last reviewed September 29, 2026