C.W.K.
Stream
Lesson 04 of 05 · published

Generics — TypeVar, Generic, Self

~22 min · generic, typevar, self, paramspec

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete

Why generics — making types parametric

Sometimes a function or class works for any type, but you want to express "the input and output are the same type." def first(items: list[T]) -> T says "a list of T returns a T." The T is a type variable — a placeholder that the type checker resolves per-call.

TypeVar — the workhorse

T = TypeVar("T") creates a type variable. Use it in function signatures or as a class parameter (with Generic[T] in the bases). Bound variables — T = TypeVar("T", bound=Comparable) — restrict T to subtypes of Comparable. Constrained — T = TypeVar("T", int, str) — restrict T to one of a specific set.

Generic classes — Stack[int] vs Stack[str]

A class that's generic over its element type. class Stack(Generic[T]) with methods using T in their signatures. Stack[int]() creates a stack of ints; Stack[str]() creates one of strings. The type checker infers element type and verifies operations.

Self — "the same class as me"

Methods that return their own class often want to say "return Self." Pre-3.11, you'd use TypeVar("T", bound="MyClass") as a workaround. Python 3.11 added typing.Self for this exact case — def chain(self) -> Self: means "returns whatever class actually called this." Useful for fluent interfaces and factory methods on subclasses.

ParamSpec — preserving full call signatures

For decorators that wrap functions, you want to say "the wrapper takes the same arguments as the wrapped function." P = ParamSpec("P") captures the entire parameter list (positional + keyword). Decorator hint: def wrap(fn: Callable[P, R]) -> Callable[P, R]. Tools like FastAPI use this heavily.

Code

TypeVar — generic functions·python
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

x: int = first([1, 2, 3])             # T = int
y: str = first(["a", "b", "c"])       # T = str

# Bound TypeVar — T must be a subtype of float
from typing import TypeVar
N = TypeVar("N", bound=float)

def average(values: list[N]) -> float:
    return sum(values) / len(values)

print(average([1, 2, 3]))             # ints work — int is subtype of float-ish
print(average([1.5, 2.5]))            # floats work
Generic class — Stack[T]·python
from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self):
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

    def peek(self) -> T:
        return self._items[-1]

int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
print(int_stack.pop())                # 2 — type-checked as int

str_stack: Stack[str] = Stack()
str_stack.push("hello")
# str_stack.push(42)                  # mypy error — expected str
Self (3.11+) — return-type for fluent interfaces·python
from typing import Self

class QueryBuilder:
    def __init__(self):
        self.filters: list[str] = []

    def where(self, condition: str) -> Self:
        self.filters.append(condition)
        return self

    def order_by(self, field: str) -> Self:
        self.filters.append(f"ORDER BY {field}")
        return self

# Subclass — methods return SubQuery, not QueryBuilder
class SubQuery(QueryBuilder):
    def with_extra(self) -> Self:
        self.filters.append("EXTRA")
        return self

sq = SubQuery()
result: SubQuery = sq.where("x = 1").order_by("id").with_extra()
# Self ensures the return type is SubQuery throughout the chain
ParamSpec — decorators with full signatures·python
from typing import Callable, ParamSpec, TypeVar
import functools
import time

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__}: {(time.perf_counter()-start)*1000:.2f}ms")
        return result
    return wrapper

@timed
def add(a: int, b: int) -> int:
    return a + b

# The type checker knows: add still has signature (int, int) -> int
# even after the decorator. ParamSpec preserves it.

External links

Exercise

Implement a generic class Cache(Generic[K, V]) with get(key: K) -> V | None and set(key: K, value: V) -> None. Use two TypeVars. Then create Cache[str, int]() and demonstrate type checker would catch passing wrong types. Then implement a decorator log_calls using ParamSpec that preserves the wrapped function's full signature. Verify (mentally) that mypy retains the original signature.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.