Skip to main content

Type hints on functions & classes

Level 3: Level 3: Patternsmedium16 mintype-hintsannotationsmodulesmypy

Annotate functions with precise types while implementing a small module.

Types you write down, but Python won't enforce

A type hint is a note you attach to a name saying what kind of value belongs there. When you review a stranger's function, the signature def average(values: list[float]) -> float states the contract in one line: pass a list of floats, get a float back. Without hints you are reverse-engineering intent from the body. On real teams, hints plus a checker like mypy or pyright catch a whole class of bugs (passing a str where an int was meant) in CI, before the code ever runs.

A hint is metadata, not a check

Check yourself
average is declared as: def average(values: list[float]) -> float. A caller in another module runs average(['a', 'b']). Where does that go wrong?

Python records annotations but never acts on them at runtime. average(["a", "b"]) will happily start executing and only blow up inside sum (a TypeError from 0 + "a"), not at the call site. The payoff comes from tools that read the annotations: your editor's autocomplete, and static checkers run as a build step.

Table
Compare the middle column with the right one: at RUNTIME they are identical, because the hint changes nothing Python does. Only the highlighted column moves the failure earlier, which is why hints without a checker in CI buy editor help and documentation, but no safety.
WhenHints plus a checker in CIHints but no checkerNo hints at all
As you typethe editor flags it immediatelythe editor may flag itnothing
In CIthe build fails before mergepassespasses
At runtimenever reachedTypeError deep inside sum()TypeError deep inside sum()
Compare the middle column with the right one: at RUNTIME they are identical, because the hint changes nothing Python does. Only the highlighted column moves the failure earlier, which is why hints without a checker in CI buy editor help and documentation, but no safety.
def average(values: list[float]) -> float:
    if not values:
        return 0.0
    return sum(values) / len(values)
Check yourself
The parameter is annotated list[float] and the return is annotated float. You call average([2, 4]), passing two ints. What prints?

values: list[float] annotates the parameter; -> float annotates the return. sum(values) / len(values) is always a float because / is true division, so the return hint is honest. (Passing [2, 4], which are int, still type-checks: a checker treats int as an acceptable float.)

Common shapes

name: str = "Ada"
counts: dict[str, int] = {}
pair: tuple[int, int] = (1, 2)
scores: list[float] = []

On classes

Annotate constructor parameters and the attributes they set. A @dataclass generates __init__ from the annotations for you.

class Account:
    def __init__(self, balance: float) -> None:
        self.balance: float = balance

-> None on __init__ is the convention: it returns nothing.

Across modules

Hints behave identically when code spans files. In the stats package, stats/summary.py can import a helper (from stats.rounding import round2) and annotate average exactly as it would in a single file. The annotation documents the boundary so a caller in another module knows the shape without opening the source.

Pitfalls

Check yourself
At a Python prompt you type round(2.5) and then round(3.5). What comes back?
  • Hints are not runtime validation. If you need to reject bad input at runtime, you still write an explicit check or reach for a validator like pydantic. -> float guarantees nothing on its own.
  • round on floats can surprise you. Two separate effects combine. First, round breaks exact ties to the nearest even digit, so round(2.5) is 2, not 3. Second, most decimals are not exactly representable: 2.675 is stored as 2.67499..., so round(2.675, 2) returns 2.67, not 2.68, because the stored value is already below the tie. Expect small surprises when the Practice round2 helper trims a mean to 2 decimals.

Interview nuance: the interpreter ignores type hints at runtime. They are stored in a function's __annotations__ and read only by tools and libraries that opt in (checkers, editors, @dataclass, pydantic); the interpreter itself does zero type checking. So typed Python buys a build-time guarantee, not a runtime one, which is exactly why teams pair hints with mypy in CI rather than trusting them at the call site.

Check yourself

Sort each of these by whether it actually reads the type hints you wrote.

mypy running as a CI step
The interpreter executing a function call
Your editor's autocomplete and red squiggles
@dataclass generating __init__ for a class
pydantic validating an incoming API payload
The + operator deciding how to add two values
Worked example (Python)
def average(values: list[float]) -> float:
    if not values:
        return 0.0
    return sum(values) / len(values)


print(average([2, 4]))   # 3.0
print(average([]))       # 0.0

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement average(nums). Return the mean of nums rounded to 2 decimals, or 0.0 for an empty list.

For [10, 20, 35] return 21.67. Add type hints: def average(nums: list[float]) -> float.

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement average(values) in stats/summary.py: return 0.0 for an empty list, otherwise the mean rounded with the read-only round2 helper imported from stats.rounding. Annotate it as def average(values: list[float]) -> float. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.