Advanced · Lesson 59 of 94

Type hints

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

A type hint is a note attached to a name saying what kind of value it is meant to hold: def total(prices: list[float]) -> float:. Python reads the note, stores it, and otherwise ignores it. Passing a list of strings will not raise an error at the call. The hints exist for two audiences: people reading the code, who now see the shape of every function without reading its body, and tools, which check that the shapes agree across a whole codebase and flag the mismatches before anything runs. The next lesson is about the tool; this one is about writing the hints.

Type hints: def send(to: str, retries: int = 3) -> bool: → readers see the contract → mypy / editor check callers → Python stores them in __annotations__ and runs as usual
Type hints · Figure 1 · Open for full size

The everyday vocabulary is small. Built-in types name themselves: int, str, float, bool, bytes. Containers take their item types in brackets: list[str], dict[str, int], tuple[int, int], set[str]. A value that may be missing is str | None, read "string or None", and it is the single most useful hint because it tells callers they must handle the None. X | Y unions cover a value that can be one of several types. Your own classes, dataclasses, and enums are types too, used by name.

Hint Means
int, str, float, bool exactly that built-in type
list[str] a list whose items are strings
dict[str, int] string keys, integer values
tuple[int, str] a pair: an int then a str
tuple[int, ...] any number of ints
str | None a string, or None
int | str either type
Callable[[int, str], bool] a function taking (int, str), returning bool
Iterable[str], Sequence[int] anything loopable / indexable of that type
Any opt out; anything goes
None as a return the function returns nothing

Prefer the abstract collection types from collections.abc for parameters: a function that only loops over its argument should say Iterable[str], not list[str], so callers can pass a tuple, a set, or a generator. Use the concrete type for return values, since you know exactly what you are handing back. Callable describes functions passed as arguments, and Any is the escape hatch for values you genuinely cannot describe; use it rarely and on purpose. Type aliases give a long hint a name: type UserId = int (3.12 syntax) or Matrix = list[list[float]].

Since Python 3.14, annotations are evaluated lazily: the expressions after the colons are not run when the function is defined, only when something asks for them through annotationlib or typing.get_type_hints. That means a method can mention its own class, or a class defined later in the file, without quotes or a from __future__ import annotations line. It also means a typo in an annotation does not crash at import time, which is exactly why hints are for tools: the checker will still catch it.

Type hints: Python ≤ 3.13: annotations evaluated at def time; forward references need quotes → Python 3.14: evaluated lazily; class Node: def link(self, other: Node) works
Type hints · Figure 2 · Open for full size

Code

A function with full hints, called correctly and incorrectly. The incorrect call runs anyway, which is the point: hints do not enforce, they inform.

# hints_basic.py
def total(prices: list[float], tax_rate: float = 0.2) -> float:
    """Sum the prices and add tax."""
    return round(sum(prices) * (1 + tax_rate), 2)


def greeting(name: str | None) -> str:
    return "hello, stranger" if name is None else f"hello, {name}"


print(total([10.0, 5.5]))
print(greeting("ada"), "|", greeting(None))
print(total.__annotations__)
print(total(["10", "5"]) if False else "a wrong call would still run; mypy flags it")
Output
18.6
hello, ada | hello, stranger
{'prices': list[float], 'tax_rate': <class 'float'>, 'return': <class 'float'>}
a wrong call would still run; mypy flags it

Containers, optionals, callables, and abstract types from collections.abc, on a small set of functions. The variable annotation documents a container before it is filled.

# hints_collections.py
from collections.abc import Callable, Iterable, Sequence


def word_lengths(words: Iterable[str]) -> dict[str, int]:
    return {w: len(w) for w in words}


def first_and_last(items: Sequence[int]) -> tuple[int, int]:
    return items[0], items[-1]


def apply_all(values: list[float], fn: Callable[[float], float]) -> list[float]:
    return [fn(v) for v in values]


scores: dict[str, list[int]] = {}
scores.setdefault("ada", []).append(91)

print(word_lengths(("ada", "grace")))
print(word_lengths({"linus"}))
print(first_and_last([4, 9, 2]))
print(apply_all([1.0, 2.5], lambda v: v * 2))
print(scores)
Output
{'ada': 3, 'grace': 5}
{'linus': 5}
(4, 2)
[2.0, 5.0]
{'ada': [91]}

Your own types in hints, plus aliases with the 3.12 type statement. A dataclass and an enum are used as types like any built-in.

# own_types.py
from dataclasses import dataclass
from enum import Enum

type UserId = int
type Matrix = list[list[float]]


class Role(Enum):
    ADMIN = "admin"
    USER = "user"


@dataclass
class User:
    id: UserId
    name: str
    role: Role = Role.USER


def find(users: list[User], user_id: UserId) -> User | None:
    return next((u for u in users if u.id == user_id), None)


def scale(m: Matrix, factor: float) -> Matrix:
    return [[v * factor for v in row] for row in m]


users = [User(1, "ada", Role.ADMIN), User(2, "linus")]
print(find(users, 2), find(users, 9))
print(scale([[1.0, 2.0], [3.0, 4.0]], 0.5))
print(UserId, UserId.__value__)
Output
User(id=2, name='linus', role=<Role.USER: 'user'>) None
[[0.5, 1.0], [1.5, 2.0]]
UserId <class 'int'>

Lazy annotations in 3.14. A method refers to its own class with no quotes, a typo in an annotation does not break the import, and get_type_hints evaluates them on demand.

# lazy_annotations.py
import typing


class Node:
    def __init__(self, value: int, next_node: Node | None = None):
        self.value = value
        self.next_node = next_node

    def link(self, other: Node) -> Node:
        self.next_node = other
        return other


def broken_hint(x: NotARealType) -> int:
    return x


chain = Node(1).link(Node(2))
print(chain.value)
print(typing.get_type_hints(Node.link))
print("import succeeded despite the bad hint; mypy would report it")
Output
2
{'other': <class '__main__.Node'>, 'return': <class '__main__.Node'>}
import succeeded despite the bad hint; mypy would report it

Try it yourself

Create typed_inventory.py. Write a Product dataclass with typed fields, a type Inventory = dict[str, Product] alias, and functions add(inv: Inventory, product: Product) -> None, find(inv: Inventory, name: str) -> Product | None, total_value(inv: Inventory) -> float, and apply_discount(inv: Inventory, rule: Callable[[Product], float]) -> dict[str, float]. Hint every parameter and return, run it to confirm it works, and keep it for the next lesson, where mypy will check it.

Common mistakes

What's next

The hints are written. Next you will run the tool that reads them, mypy, and learn the advanced forms it understands: Protocols, TypedDicts, and generics.

Course overview