skip to content

Generics and Variance

Writing code once for many element types: type variables, generic classes, list[int] and dict[str, int], and the rules deciding which type stands in for which. Asked of anyone owning a shared library.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

24

How do `list[int]` and `typing.List[int]` differ in Python 3.14?

level: juniorimportance: must knowfreq 62%

answer

  1. Two spellings, one of them historical
  2. Builtins could not be subscripted before 3.9
  3. PEP 585 made the classes themselves generic
  4. Deprecated, still importable, no runtime warning

basics

~20 s

Both say 'a list of ints' to a type checker. Since PEP 585 in Python 3.9 the builtin list is subscriptable itself, so list[int] is the current spelling and typing.List is a deprecated alias kept only for older code.

solid answer

~40 s

They are interchangeable to a type checker, and only one of them is current. Before 3.9 the builtins were not subscriptable, so `typing` shipped capitalised aliases — `List`, `Dict`, `Set`, `Tuple`, `FrozenSet` — purely so annotations could be written at all. **PEP 585 in Python 3.9** gave the builtins and the `collections.abc` classes a subscript hook, so `list[int]`, `dict[str, int]`, `set[str]` and `collections.abc.Sequence[str]` now work directly, and the `typing` aliases were deprecated the same release. On 3.14 they still exist and emit no runtime warning — PEP 585 promised no removal for at least five years — but new code should not use them, and most linters flag them. At runtime the two are different objects: `typing.List[int] == list[int]` is `False`, even though every checker treats them as the same type.

code

pycon · 9 lines
pycon
>>> import typing
>>> list[int]
list[int]
>>> type(list[int])
<class 'types.GenericAlias'>
>>> typing.List[int]
typing.List[int]
>>> typing.List[int] == list[int]
False

go deeper

for a junior

Be ready to write modern signatures without thinking: list[int], dict[str, int], set[str]. Know that the capitalised typing.List spelling is the old way, kept working for old code, and that PEP 585 in 3.9 is where the change happened.

for a middle

Explain why the duplicate existed — builtins were not subscriptable before 3.9 — and that the deprecation is documentation-level, with no runtime warning to catch it. Be able to list the mechanical rewrite, including typing.Optional becoming | None in 3.10.

for a senior

Own the migration story for a real codebase: which typing names go away, which stay because they are not duplicates, and why mixed spellings hurt any code that inspects annotation objects. Point at the linter rule rather than relying on people to remember.

for a principal

Frame it as a house-style decision with a minimum-interpreter constraint attached. Decide once, encode it in the lint configuration so it is not re-argued in review, and treat the same way any future syntax-level replacement of a library workaround.

### Why there were ever two spellings Type hints arrived in Python 3.5, but the builtin container classes were not subscriptable then: writing `list[int]` at runtime raised `TypeError: 'type' object is not subscriptable`. So the `typing` module shipped a parallel set of capitalised aliases — `typing.List`, `typing.Dict`, `typing.Set`, `typing.FrozenSet`, `typing.Tuple`, `typing.Type` — whose only job was to be subscriptable stand-ins for the real classes. A whole generation of code learned to import them, and a whole generation of interviewers learned to check whether you still do. ### What PEP 585 changed in 3.9 PEP 585, landed in **Python 3.9**, gave the builtin containers and the `collections.abc` classes a class-level subscript hook (`__class_getitem__`), so the classes themselves became generic. From 3.9 onward you write: ```python def reprice(prices: dict[str, int], skus: set[str]) -> list[tuple[str, int]]: ... ``` The same release deprecated every `typing` alias that PEP 585 made redundant. The deprecation is documentation-level: the aliases are still importable on 3.14, still work, and raise **no** `DeprecationWarning` at runtime, because PEP 585 explicitly promised not to remove them for at least five years and no removal has been scheduled. That is why old code keeps running and why the habit persists. ### The two objects are not the same object To a type checker `list[int]` and `typing.List[int]` denote the identical type. At runtime they are different values: `list[int]` is a `types.GenericAlias`, a small object remembering the class and its parameters, while `typing.List[int]` is a `typing`-module alias object, and they do not compare equal. Code that pattern-matches on annotation objects therefore has to handle both shapes — one more reason to converge on a single spelling in a codebase rather than mixing them file by file. ### What still lives in `typing` PEP 585 replaced only the aliases that duplicate a real runtime class. Plenty of `typing` is not duplication and stays: `typing.Any`, `typing.Protocol`, `typing.Literal`, `typing.Final`, `typing.ClassVar`, `typing.TypeVar`, `typing.NamedTuple`, `typing.TypedDict`, `typing.cast`, `typing.NoReturn`. A second family was superseded by syntax rather than by a builtin: `typing.Optional[X]` and `typing.Union[X, Y]` are written `X | None` and `X | Y` since **Python 3.10** (PEP 604). And `typing.Callable` has a non-deprecated home too — `collections.abc.Callable` — which PEP 585 made subscriptable alongside the rest. ### A subtlety that trips people: annotation position versus runtime position On 3.7 and 3.8, `from __future__ import annotations` (PEP 563) turned annotations into strings that were never evaluated, so `def f(x: list[int]) -> None` *looked* fine even though `list[int]` would have raised if actually executed. That worked only in annotation position. Assigning a type alias — `Rows = list[tuple[str, int]]` — or calling `typing.cast(list[int], value)` evaluates the expression for real, and on 3.8 that still failed. The lesson survives the version: an annotation is not always evaluated, but a plain expression always is. On **Python 3.14** the lazy behaviour became the default under PEP 649/749: annotations are compiled into a separate function and evaluated only when something asks for them, so a bad annotation does not blow up at definition time at all. `def pick(rows: list[NotDefinedYet]) -> None: pass` defines cleanly; the `NameError` appears only when the annotations are read. That makes an unused wrong annotation quieter than it used to be, which is an argument for running a static checker rather than trusting import to catch it. ### Migrating an existing codebase The change is mechanical and safe: drop the capitalised import, lowercase the name. `List` becomes `list`, `Dict` becomes `dict`, `Set` becomes `set`, `FrozenSet` becomes `frozenset`, `Tuple` becomes `tuple`, `Type` becomes `type`, `typing.Deque` becomes `collections.deque`, and the `typing` re-exports of the abstract collections (`typing.Sequence`, `typing.Mapping`, `typing.Iterable`) become the real classes from `collections.abc`. Automated tooling in the linting ecosystem does the rewrite; the only real constraint is the minimum interpreter version you support, and 3.9 has been end-of-life for long enough that on a 3.14 target there is nothing left to weigh. ### What an interviewer is actually testing Not trivia about PEP numbers. They are checking whether your habits track the language: whether you know why the duplicate spelling exists, that it is deprecated rather than broken, that no runtime warning will tell you, and that the fix is a one-line lowercase. Saying "`typing.List` is needed for type checking to work" is the answer that ends the line of questioning badly.

  • Does importing typing.List emit a DeprecationWarning on 3.14?
    No. The deprecation is documented but not enforced at runtime: `typing.List[int]` evaluates silently on 3.14. PEP 585 committed to leaving the aliases in place for at least five years after 3.9 and no removal release has been announced, so nothing in the interpreter will nudge you. A static checker or a linter rule is what actually flags the old spelling.
  • How would you spell an optional list of SKU strings today?
    `list[str] | None`. PEP 604 in Python 3.10 made `X | Y` valid in annotations, which retires both `typing.Optional[list[str]]` and `typing.Union[list[str], None]`. The `|` form is also a real runtime expression outside annotations, and `isinstance(x, int | str)` is accepted from 3.10 onward — unlike a parameterised generic such as `list[int]`, which `isinstance` rejects.
  • Why did some pre-3.9 code write list[int] with `from __future__ import annotations`?
    Because PEP 563 turned annotations into unevaluated strings, so the interpreter never executed `list[int]` and the fact that builtins were not subscriptable on 3.7/3.8 did not matter. The trick covered annotation position only: a type alias assignment or a `typing.cast` call evaluates the expression for real and still failed. On 3.14 annotations are lazy by default anyway.

The typing aliases are the old street name on a building that was renumbered years ago: the post still arrives, but nobody prints it on new stationery.

saying these in an interview costs you the question

  • Says typing.List is required for a type checker to work
  • Thinks list[int] validates elements at runtime
  • Claims typing.List was removed in Python 3.9
  • Believes list[int] needs a __future__ import on 3.14
  • Assumes typing.List[int] == list[int] compares True
  • Mixes both spellings in one codebase without noticing

context

open as a page

Why annotate a parameter as Iterable[str] rather than Iterator[str] or list[str]?

level: juniorimportance: must knowfreq 58%

basics

~10 s

collections.abc.Iterable[str] says only that the function will loop over the argument, so lists, sets, dict views and generators all fit. Iterator[str] demands a one-shot cursor, and list[str] rejects every other container.

open as a page

What does the bracket syntax in `def first[T](xs: list[T]) -> T` declare in Python?

level: juniorimportance: must knowfreq 42%

basics

~20 s

The brackets declare a type parameter T belonging to that function, tying the argument's element type to the return type. Python 3.12 added the syntax in PEP 695; before it you declared T with a module-level TypeVar call.

open as a page

Why annotate a Python helper with a TypeVar instead of Any?

level: juniorimportance: must knowfreq 70%

basics

~20 s

A TypeVar is a placeholder that ties the argument type to the return type, so a checker knows a helper called with a list of strings gives back a string. Any severs that link and switches checking off for everything downstream.

open as a page

How does typing.ParamSpec let a decorator preserve the wrapped function's signature?

level: middleimportance: must knowfreq 48%

basics

~20 s

ParamSpec is a type variable that captures a whole parameter list. Typing a decorator as Callable[P, R] to Callable[P, R], with the inner wrapper declared *args: P.args, **kwargs: P.kwargs, hands callers back the original signature instead of erasing it.

open as a page

How does TypeVar('T', bound=X) differ from TypeVar('T', X, Y)?

level: middleimportance: must knowfreq 55%

basics

~20 s

bound=X is an upper bound: the parameter may be solved to X or any subtype, and the solution keeps the subtype. A value-constraint list means the parameter must be solved to exactly one of the listed types, and a subclass is widened to the listed one.

open as a page

Why does a type checker reject a list[Dog] argument where list[Animal] is expected?

level: middleimportance: must knowfreq 60%

basics

~20 s

Because list is invariant: list[Dog] and list[Animal] are unrelated types even though Dog subclasses Animal. A function holding a list[Animal] may append a Cat, which would corrupt the caller's list of dogs, so the checker refuses the call.

open as a page

What does Callable[[int], str] mean, and how does Callable[..., str] differ?

level: juniorimportance: should knowfreq 46%

basics

~10 s

Callable[[int], str] describes a function taking exactly one int parameter and returning str. Callable[..., str] pins only the return type: the literal ellipsis means the parameter list is unchecked, so any signature is accepted.

open as a page

Why does `isinstance(x, list[int])` raise TypeError at runtime?

level: middleimportance: should knowfreq 33%

basics

~20 s

Because list[int] is not a class: it is a types.GenericAlias describing a class plus its parameters, and CPython refuses to check one. Annotations are never enforced at runtime, so nothing verifies the element types for you.

open as a page

How does `tuple[int, ...]` differ from `tuple[int, str]` in a signature?

level: middleimportance: should knowfreq 44%

basics

~10 s

tuple[int, ...] is a homogeneous tuple of any length, including empty. tuple[int, str] is exactly two elements: an int then a str. The literal ellipsis means 'more of the same', never 'and whatever else'.

open as a page

Why is AsyncIterator[bytes], not Awaitable[bytes], the return annotation for an async generator?

level: middleimportance: should knowfreq 40%

basics

~20 s

Calling an async def function that contains yield hands back an async generator object, which you drive with async for, never with await. So it is annotated AsyncIterator[bytes]; Awaitable[bytes] describes something awaited once for a single value.

open as a page

When does a generator function need Generator[int, str, bool] rather than Iterator[int]?

level: middleimportance: should knowfreq 44%

basics

~20 s

Generator's three parameters are the yield type, the send type and the return type. Use the full form only when callers use send() or read the value carried by StopIteration; for a plain for-loop consumer, Iterator[int] says everything true.

open as a page

What object does Python's `type Point[T] = tuple[T, T]` statement create at runtime?

level: middleimportance: should knowfreq 34%

basics

~20 s

It creates a typing.TypeAliasType object named Point, not a plain name binding. The right-hand side is stored unevaluated and computed the first time you read its value attribute, so an alias may reference names defined later.

open as a page

When subclassing a typing.Generic base, how do Base[int] and Base[T] differ?

level: middleimportance: should knowfreq 35%

basics

~20 s

class C(Base[int]) fixes the parameter: C is an ordinary non-generic class whose inherited members are all typed with int. class C(Base[T]) passes the parameter through, so C is itself generic and callers write C[str]. Inheriting bare Base fixes nothing and silently means Base[Any].

open as a page

Why accept Sequence[Animal] but return list[Animal] from a public function?

level: middleimportance: should knowfreq 48%

basics

~20 s

Parameters should be the widest read-only type the body needs, so callers can pass a list, a tuple or a list of a subclass. Returns should be the concrete type you built, so callers keep its full API.

open as a page

Why annotate a parameter `collections.abc.Mapping[str, int]` rather than `dict[str, int]`?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Mapping is the abstract interface, so the parameter accepts any mapping a caller already has, not only a real dict. It also states that the function will not mutate the argument, because the abstract Mapping has no item assignment.

open as a page

When does a decorator's type need typing.Concatenate rather than a bare ParamSpec?

level: seniorimportance: should knowfreq 24%

basics

~20 s

Use Concatenate when the decorator changes the parameter list instead of copying it — supplying a leading positional argument the caller no longer passes, or demanding an extra leading one. A bare ParamSpec can only reproduce the signature unchanged.

open as a page

Which return annotation belongs on a @contextlib.contextmanager-decorated function?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Annotate the undecorated generator body Iterator[T], where T is the type yielded to the as-target. The decorator turns that function into one returning a context manager, and parameters that accept the manager itself are annotated contextlib.AbstractContextManager[T].

open as a page

What does the implicit scope created by `def load[T]` give you that a module-level TypeVar cannot?

level: seniorimportance: should knowfreq 27%

basics

~10 s

The compiler wraps the declaration in a hidden scope holding T, so the name never reaches the module, each declaration owns a fresh parameter rather than sharing one, and bounds resolve lazily there.

open as a page

In a Generic[T] class, when should a method declare its own TypeVar?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Declare a second TypeVar on the method whenever the method introduces a type the instance does not already carry — a transform's result type, or a caller-supplied default. The class parameter is fixed once at instantiation; a method-scoped parameter is solved fresh at every call.

open as a page

Why can a Callable[[Animal], None] be passed where Callable[[Dog], None] is expected?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Because a callable's parameter positions are contravariant. The slot promises the callback will only ever be handed a Dog, and a function that handles any Animal handles that. The reverse direction is the unsafe one.

open as a page

What does the `= int` do in Python's `class Box[T = int]` declaration?

level: middleimportance: nice to knowfreq 14%

basics

~20 s

It gives the type parameter T a default, so an unsubscripted Box is read as Box[int] by a type checker. PEP 696 added defaults in Python 3.13; at runtime the parameter reports that it has one.

open as a page

What does typing.TypeVarTuple express that a single TypeVar cannot?

level: seniorimportance: nice to knowfreq 14%

basics

~20 s

A TypeVar stands for one type; a TypeVarTuple stands for an arbitrary-length sequence of types. It lets a signature carry a variable number of heterogeneous positional types through, such as tuple[int, *Ts] preserving each element's type.

open as a page

What does TypeVar("T_co", covariant=True) change about a Generic class using it?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

It makes the class covariant in that parameter, so Box[Dog] is usable where Box[Animal] is expected. In exchange the checker requires the parameter to appear only in output positions; using it as a method parameter is an error.

open as a page