Skip to main content

More About Type Hints

Source: Fluent Python, 2nd Edition — Chapter 15 by Luciano Ramalho

Chapter 8 was the first pass at gradual typing. This sequel is the awkward parts: @overload, TypedDict, cast, reading hints at runtime, and generics (including variance and generic protocols).

Guido’s line: small programs love dynamism; large ones want a language that can be strict. You still do not need 100% annotations. Pythonic APIs are often the hardest to hint — max needs six overloads.

Overloaded signatures​

When the return type depends on which arguments you passed, one signature is a lie. @typing.overload lists legal shapes. The checker tries them in order. In a .pyi stub, overloads are the whole story. In a .py file they sit above the real def, which usually has no hints:

@overload
def sum(it: Iterable[T]) -> T | int: ...

@overload
def sum(it: Iterable[T], /, start: S) -> T | S: ...

def sum(it, /, start=0):
return functools.reduce(operator.add, it, start)

Empty sum([]) is int because start defaults to 0 — that is why the first return is T | int. Typeshed’s builtins.pyi is full of this (__arg prefixes mark positional-only).

max is worse: iterable or two-or-more args, plus key and default. A SupportsLessThan protocol (__lt__) plus six overloads lets Mypy reject max([None, None]). Copy-pasting the same block for min is the tax. João Bueno: the idea of max fits in a head; the annotation language does not.

Empty input: add([]) is 0, add([], 10) is 10.

TypedDict​

A JSON book record has different value types per key. dict[str, Any] is mush. dict[str, str | int | list[str]] does not tie title to str.

TypedDict (PEP 589) is a checker-only record shape. At runtime BookDict(...) is dict. No attributes, no defaults, no methods. Wrong authors='Jon' is silent until a checker or pydantic (or similar) validates.

class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: int

Mypy will flag assigning str to authors, adding weight, deleting title. json.loads returns Any. Annotating whatever: BookDict = json.loads(data) silences --disallow-any-expr and still does not make the JSON a book. Garbage in, garbage XML out. isinstance(value, list) in to_xml is what the checker understands; key == 'authors' was not enough in the book’s example.

Optional keys and inheritance exist in PEP 589. They still are not a JSON schema.

Quiz

TypedDict

1 / 1

BookDict(title=..., isbn=...) returns

Pick an answer to see if you are right.

cast​

typing.cast(typ, val) returns val unchanged. The checker must believe typ. Use it when typeshed or inference is wrong (list[object] item that you already proved is str; asyncio.Server.sockets lagging the real tuple).

Overuse is a smell. # type: ignore says less. Any infects inference. Occasional cast is what Guido called fine. It does not catch [][0].

Reading hints at runtime​

Import time used to evaluate annotations into __annotations__ (str, not 'str'). That costs CPU/RAM and needs quotes for forward refs (-> 'Rectangle' inside the class).

from __future__ import annotations (PEP 563) stores strings. typing.get_type_hints resolves many of them. It fails on inner classes, some factories, some self-types. FastAPI/pydantic pushed back; default postponed evaluation slipped. Prefer get_type_hints, wrapped in your helper so one place can absorb future changes (Checked._fields).

Do not parse __annotations__ yourself if you can avoid it.

Generic classes​

class LottoBlower(Tombola, Generic[T]):
def pick(self) -> T: ...

Users write LottoBlower[int]. Generic type = declared with variables. Formal parameter = T. Parameterized type = LottoBlower[int]. Actual parameter = int. Mutable collections stay invariant: list[int] is not a list[float].

Variance​

School: only a juice dispenser. Invariant BeverageDispenser[Juice] rejects both generic Beverage and specialized OrangeJuice. Covariance (TypeVar(..., covariant=True), name T_co): orange juice dispenser OK; any-beverage still not. Same direction as the beverage hierarchy. Immutable / output-only types (FrozenSet, iterators) can be covariant.

Trash: need a can that accepts biodegradable. Contravariant T_contra: a TrashCan[Refuse] (more general) is OK; TrashCan[Compostable] is too picky. Sinks reverse the hierarchy. Generator / Coroutine mix a contravariant send type with a covariant yield type.

Rules of thumb: out → co, in after construct → contra, both → invariant. Default TypeVar is invariant. When unsure, stay invariant.

Quiz

Variance

1 / 2

list[int] vs list[float] is

Pick an answer to see if you are right.

Generic static protocols​

SupportsAbs[T_co] is a one-method protocol; __abs__ returns T_co. is_unit(v: SupportsAbs[float]) accepts Vector2d, complex, int. RandomPicker[T_co] tightens Chapter 13’s Any pick.

T_co = TypeVar('T_co', covariant=True)

class RandomPicker(Protocol[T_co]):
def pick(self) -> T_co: ...

This chapter is not the whole type system. Robust Python (Viafore) and Mypy’s docs keep up better than the language reference.