Generics and collections¶
A list is a list of something. For a type-checker to be useful, it needs to know what that something is — a list[int] is very different from a list[str] when it comes to what methods you can safely call on its elements.
This notebook covers generic types over collections (list[int], dict[str, int], etc.) and the abstract-container types (Iterable, Sequence, Mapping) that make function signatures more flexible.
Typing built-in collections¶
Put the element type in square brackets. These are all valid on Python 3.9+:
scores: list[int] = [85, 92, 78]
names: set[str] = {"Alice", "Bob"}
ages: dict[str, int] = {"Alice": 30, "Bob": 25}
print(scores)
print(names)
print(ages)
dict[K, V] takes two parameters — the key type and the value type. tuple can take any number of them, one per position (covered below).
On Python 3.8 and earlier you had to import these forms from the typing module: List[int], Dict[str, int], Set[str]. The modern built-in forms are shorter and should be preferred. The typing reference covers the legacy syntax if you need it.
Tuples — two different shapes¶
Tuples come in two flavours with different annotation syntax:
Fixed-length, heterogeneous: tuple[str, int, bool] — a 3-tuple with specific types in each position:
user: tuple[str, int, bool] = ("Alice", 30, True)
# user = ("Alice", 30) # would be a type error — wrong length
# user = (30, "Alice", True) # would be a type error — wrong types
print(user)
Variable-length, homogeneous: tuple[int, ...] — a tuple of any number of ints. The ... (literally an ellipsis) is part of the syntax.
primes: tuple[int, ...] = (2, 3, 5, 7, 11, 13)
print(primes)
print(primes[0])
The heterogeneous form (tuple[str, int, bool]) is closer to a named-tuple or record — each position has a meaning. For "variable-length list of integers that happens to be immutable", use tuple[int, ...]. They're genuinely different shapes.
Nesting¶
Generics compose — a list of dicts, a dict of lists, whatever you need:
users: list[dict[str, str]] = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
]
scores_by_team: dict[str, list[int]] = {
"red": [12, 18, 9],
"blue": [15, 11, 14],
}
print(users[0]["name"])
print(scores_by_team["red"])
A dict[str, list[int]] is a dict where keys are strings and values are lists of integers. You can go as deep as you need, but at some point the annotation gets unreadable — past two levels of nesting, consider a TypedDict or a dataclass instead (covered in the next notebook and the data structure recipe).
Abstract containers — Iterable, Sequence, Mapping¶
A function that iterates over its argument doesn't really need a list — any iterable will do. Annotating it list[int] is too restrictive; the abstract Iterable[int] is more flexible:
from collections.abc import Iterable
def total(numbers: Iterable[int]) -> int:
return sum(numbers)
# Accepts a list, tuple, set, generator — anything iterable
print(total([1, 2, 3]))
print(total((1, 2, 3)))
print(total({1, 2, 3}))
print(total(i for i in range(4)))
The common hierarchy — each row accepts everything below it:
| Type | Supports | Use when you need |
|---|---|---|
Iterable[T] |
for x in xs: |
To iterate once |
Iterator[T] |
next(xs) |
To pull elements lazily |
Collection[T] |
len(xs), in, iteration |
Size and membership |
Sequence[T] |
xs[0], xs[1:5] |
Indexed access (list, tuple) |
MutableSequence[T] |
.append(), .insert() |
To modify (list only) |
Mapping[K, V] |
xs[k], k in xs |
Read-only dict-like |
MutableMapping[K, V] |
xs[k] = v, .update() |
To modify the mapping |
Rule of thumb: annotate parameters with the most abstract type that supports what the function actually needs; annotate return types with the concrete type so callers know what they get. def items(d: Mapping[K, V]) -> list[V]: — accept a broad range, return a specific thing.
Imports come from collections.abc (preferred) or typing (legacy but still works).
from collections.abc import Mapping
def get_or_default(data: Mapping[str, int], key: str, default: int = 0) -> int:
return data.get(key, default)
# Accepts any mapping — dict, ChainMap, MappingProxyType, etc.
print(get_or_default({"a": 1, "b": 2}, "a"))
print(get_or_default({"a": 1, "b": 2}, "c", default=99))
Custom generics with TypeVar¶
If you write a function that takes a list and returns one of its elements, you want the element type in the return to match the element type of the input. A TypeVar is how you express that relationship:
from typing import TypeVar
from collections.abc import Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
# The return type matches the input's element type
print(first([1, 2, 3])) # int
print(first(["a", "b", "c"])) # str
print(first((True, False))) # bool
The T is the same type across the annotation — if you pass a list[str], you get a str back. Without a TypeVar, you'd have to write items: Sequence[Any]) -> Any, losing all the type information.
One subtlety: each call-site binds T to a single concrete type. A function annotated def pair(a: T, b: T) -> tuple[T, T] won't accept pair(1, "a") — T can't be both int and str at once. Use T | U or two separate TypeVars if you need independent types.
On Python 3.12+ there's a cleaner syntax — def first[T](items: Sequence[T]) -> T: — the type parameter is declared in the function signature directly. The TypeVar form works on every version from 3.5 onwards, so we'll keep using it here.
Constraining a TypeVar¶
By default T accepts any type. You can constrain it two ways:
# Bound: T must be a subtype of this
from typing import TypeVar
Number = TypeVar("Number", bound=float) # T can be float or any subtype (int, etc.)
def double(x: Number) -> Number:
return x * 2
print(double(5)) # int — still matches because int is a subtype of float in typing
print(double(3.14))
# Constraint set: T must be one of these exact types
TextLike = TypeVar("TextLike", str, bytes)
def first_char(s: TextLike) -> TextLike:
return s[:1]
print(first_char("hello"))
print(first_char(b"world"))
Use bound= when you want "any subtype of X" (allowing subclasses). Use a constraint set when you want "one of these specific types" and want the type-checker to treat them as separate branches. Most of the time, bound= is the one you reach for.
Exercise¶
Annotate the following functions and variables. Use the most abstract container type that supports what the function actually does.
# 1. A function that counts unique elements
def count_unique(items):
return len(set(items))
# 2. A function that returns the last element of a list, tuple, or string
def last(items):
return items[-1]
# 3. A dict from student names to their exam scores (multiple scores per student)
grades = {
"Alice": [85, 92, 78],
"Bob": [70, 88, 82],
}
# 4. A function that takes a dict-like of string->int and returns the largest value
def max_value(data):
return max(data.values())
print(count_unique([1, 2, 2, 3]))
print(last("hello"))
print(max_value({"a": 10, "b": 5}))
Solution
from collections.abc import Iterable, Sequence, Mapping
from typing import TypeVar
# 1. Any iterable works, and we don't care about element type
def count_unique(items: Iterable[object]) -> int:
return len(set(items))
# 2. Needs indexing; TypeVar preserves the element type
T = TypeVar("T")
def last(items: Sequence[T]) -> T:
return items[-1]
# 3. Variable annotation
grades: dict[str, list[int]] = {
"Alice": [85, 92, 78],
"Bob": [70, 88, 82],
}
# 4. Mapping for read-only access
def max_value(data: Mapping[str, int]) -> int:
return max(data.values())
Notes:
count_uniqueusesIterable[object]because the function doesn't call any methods on the elements beyond hashing — any object works. You could also writeIterable[Any]or even justIterable— all equivalent for this case.last'sTypeVarmeans the return type matches the element type: pass alist[int], get anint.max_valueusesMapping, notdict— aMappingis anything dict-like, which is all the function needs.
Recap¶
- Generic built-ins:
list[T],set[T],dict[K, V]. Python 3.9+. - Tuples:
tuple[str, int, bool]for fixed-length;tuple[int, ...]for variable-length. - Prefer abstract
Iterable,Sequence,Mappingfor parameters; concretelist,dictfor return types. TypeVarexpresses "the return type depends on the argument type" — use when you need to preserve element types across a call.- Import abstract containers from
collections.abc(preferred) ortyping(legacy).
Next: Optional, Union, and friends — the expressive forms (Literal, Callable, TypedDict, Any) for trickier shapes.