A generic type is a type that takes another type as a parameter. We’ve used them throughout the course — list[int], dict[str, float]. This lesson covers them properly.
Built-in generics
Almost every collection type in Python accepts a generic parameter:
ages: list[int] = [25, 30, 35]
prices: dict[str, float] = {"apple": 0.5, "banana": 0.3}
unique_tags: set[str] = {"python", "ml"}
point: tuple[float, float] = (1.0, 2.0)
The square brackets read like a type-level call: list[int] is “a list of int”.
Modern Python (3.9+) — write the lowercase built-in names directly. Older tutorials use capitalised versions imported from
typing(List[int],Dict[str, float]). Avoid that — the lowercase form is the modern standard.
Tuples — fixed vs variable length
A tuple’s type hint can either describe each position:
point: tuple[int, int] = (3, 4) # two ints
record: tuple[str, int, str] = ("a", 1, "b") # three specific positions
Or describe a homogeneous tuple of any length:
nums: tuple[int, ...] = (1, 2, 3, 4, 5) # ints, any count
The ... (literally three dots) means “and the same type continues”.
Generic function signatures
A function that takes a list of strings:
def total_length(words: list[str]) -> int:
return sum(len(w) for w in words)
A function that takes any iterable:
from collections.abc import Iterable
def total_length(words: Iterable[str]) -> int:
return sum(len(w) for w in words)
Iterable[str] is more flexible than list[str] — it accepts a list, tuple, set, generator, anything iterable. Use the broadest type that fits.
Useful types from collections.abc
For function parameters, prefer the abstract types over concrete ones:
from collections.abc import Iterable, Mapping, Sequence
def my_func(
items: Iterable[int], # anything you can iterate
lookup: Mapping[str, int], # any dict-like (reading only)
ordered: Sequence[str], # any indexable, len-able sequence
) -> None:
...
You’ll see these in real codebases. They make functions accept more inputs without losing type safety.
Writing your own generic function (TypeVar)
When a function works with any type but should preserve it across the call, use a type variable:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
print(first([1, 2, 3])) # type is int
print(first(["a", "b"])) # type is str
T is a placeholder. Pyright infers what T is for each call. The return type matches the element type.
Without T, you’d have to write first(items: list[object]) -> object: — and lose all type information at the call site.
Python 3.12+ — newer syntax
Python 3.12 introduced cleaner generic syntax:
def first[T](items: list[T]) -> T:
return items[0]
The [T] is right after the function name — like generics in other languages. Either syntax works; the bracketed form is cleaner if you’re on a recent Python.
Writing your own generic class
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.pop()) # 2 — pyright knows this is an int
The class works with any type, while keeping that type consistent. Stack[int] only accepts integers; Stack[str] only strings.
Python 3.12+ has the shorter form here too:
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
When to reach for generics
You won’t write generic classes every day. The common cases:
- Container types (a stack, a queue, a cache)
- Functions that pick or transform without changing element types (
first,last,pick) - Library code aimed at being reusable
For application code, sticking with concrete types (list[User], dict[str, Product]) is usually clearer.
What’s next
Real code often deals with values that might be missing. Next — Optional and the X | None pattern.