Chapter 18 — Generics, Bounds, Constraints, and Variance
1. Opening problem
This function loses type information:
def first(
values: list[object],
) -> object:
return values[0]
When called with list[str], the result is only known statically as object.
A generic function preserves the relationship:
def first[T](
values: list[T],
) -> T:
return values[0]
Now:
name = first(
["Mika", "Lily"]
)
is inferred as str.
Generics express relationships among types. They are not merely placeholders.
2. Modern generic functions
Python 3.12+ syntax:
def identity[T](
value: T,
) -> T:
return value
The type parameter exists for static analysis and runtime introspection.
Calls:
number = identity(10)
name = identity("Steve")
The same implementation preserves each input type.
Compatibility syntax:
from typing import TypeVar
T = TypeVar("T")
def identity(
value: T,
) -> T:
return value
Existing codebases and libraries still use this form extensively.
3. Generic classes
Modern syntax:
class Box[T]:
def __init__(
self,
value: T,
) -> None:
self._value = value
def get(self) -> T:
return self._value
Usage:
name_box = Box("Steve")
score_box = Box(100)
Explicit specialization:
name_box: Box[str] = Box(
"Steve"
)
Compatibility syntax:
from typing import (
Generic,
TypeVar,
)
T = TypeVar("T")
class Box(
Generic[T]
):
...
4. Generic type aliases
type Result[T] = (
Success[T]
| Failure
)
type Pair[T] = tuple[T, T]
A generic alias preserves relationships when reused.
Avoid aliases that hide essential domain semantics behind deeply nested type expressions.
A named result class may be clearer when methods or runtime distinction matter.
5. Multiple type parameters
class MappingEntry[K, V]:
def __init__(
self,
key: K,
value: V,
) -> None:
self.key = key
self.value = value
Generic function:
def get_or_default[K, V](
mapping: dict[K, V],
key: K,
default: V,
) -> V:
try:
return mapping[key]
except KeyError:
return default
The annotations express:
- key must match the mapping key type;
- default must match the mapping value type;
- result is the value type.
6. Bounds
A bound allows a type parameter to be a particular type or subtype.
class HasName:
name: str
def display_name[
T: HasName
](
value: T,
) -> str:
return value.name
Compatibility syntax:
T = TypeVar(
"T",
bound=HasName,
)
A bound preserves the specific subtype.
def choose_longer[
T: str
](
left: T,
right: T,
) -> T:
return (
left
if len(left) >= len(right)
else right
)
A subclass of str can remain the inferred result type.
7. Protocol bounds
Bounds are more useful with behavioral protocols than concrete implementation classes.
from typing import Protocol
class SupportsClose(
Protocol
):
def close(self) -> None:
...
def close_and_return[
T: SupportsClose
](
resource: T,
) -> T:
resource.close()
return resource
The function accepts any structurally compatible object and preserves its specific type.
8. Constraints
A constrained type parameter is limited to a listed set.
def concatenate[
T: (str, bytes)
](
left: T,
right: T,
) -> T:
return left + right
Compatibility syntax:
T = TypeVar(
"T",
str,
bytes,
)
A constraint differs from a bound.
Bound:
T: str
allows subtypes of str and preserves the chosen subtype where possible.
Constraint:
T: (str, bytes)
requires one consistent allowed alternative for the call.
A union parameter is not the same:
def concatenate(
left: str | bytes,
right: str | bytes,
) -> str | bytes:
...
The union annotation permits one argument to be str and the other bytes, even though the implementation may not support mixing them.
The constrained generic expresses the relationship between arguments.
9. Constraints versus overloads
Sometimes overloads communicate behavior better:
from typing import overload
@overload
def decode(
value: bytes,
) -> str:
...
@overload
def decode(
value: str,
) -> str:
...
def decode(
value: str | bytes,
) -> str:
if isinstance(
value,
bytes,
):
return value.decode(
"utf-8"
)
return value
Use generics when output type depends systematically on input type.
Use overloads when:
- return type changes by input shape;
- parameter combinations have distinct contracts;
- a finite list of call patterns exists;
- one generic relationship cannot express the API.
10. Generic methods
class Serializer:
def serialize[T](
self,
value: T,
encoder: Encoder[T],
) -> bytes:
return encoder.encode(
value
)
The class itself does not need to be generic when only one method has a type relationship.
Do not make the entire class generic unless instance state depends on the type parameter.
11. Generic repositories
from typing import Protocol
class Entity(Protocol):
id: int
class Repository[T: Entity]:
def find(
self,
entity_id: int,
) -> T | None:
...
def save(
self,
entity: T,
) -> None:
...
This seems attractive, but consider whether every entity truly shares the same ID type and persistence contract.
A more precise repository might use separate ID and entity parameters:
class Repository[ID, T]:
def find(
self,
entity_id: ID,
) -> T | None:
...
def save(
self,
entity: T,
) -> None:
...
Generics should model real relationships, not merely create a universal abstraction.
12. Invariance
Suppose:
class Animal:
pass
class Dog(Animal):
pass
A list[Dog] is not safely usable as list[Animal].
Why?
dogs: list[Dog] = []
animals: list[Animal] = dogs
animals.append(
Animal()
)
Now dogs contains a non-dog.
Therefore mutable containers such as list are invariant.
Even though Dog is a subtype of Animal:
list[Dog] is not a subtype of list[Animal]
This surprises TypeScript developers because TypeScript's structural type system sometimes permits assignments that are unsound for mutable arrays depending on context.
13. Covariance
A producer-only abstraction can be covariant.
from collections.abc import Sequence
def feed_animals(
animals: Sequence[Animal],
) -> None:
for animal in animals:
...
A Sequence[Dog] can be accepted because the function reads animals but cannot insert an arbitrary Animal.
Mental model:
Covariance is safe for values produced as the type.
Custom compatibility syntax:
from typing import TypeVar
T_co = TypeVar(
"T_co",
covariant=True,
)
Modern type-parameter syntax can infer variance for generic classes in supported cases, while explicit legacy declarations remain common in library code.
14. Contravariance
A consumer-only abstraction can be contravariant.
from typing import Protocol
class Handler[T](
Protocol
):
def handle(
self,
value: T,
) -> None:
...
Conceptually, a handler capable of handling any Animal can handle a Dog.
Handler[Animal]
can be used where
Handler[Dog]
is expected
The direction reverses.
Mental model:
Contravariance is safe for values consumed as the type.
This is most common in callbacks, handlers, and comparators.
15. Producer, consumer, invariant
A practical rule:
- producer of
T→ covariance may be safe; - consumer of
T→ contravariance may be safe; - both produces and consumes
T→ invariance is usually required.
Example mutable box:
class Box[T]:
def get(self) -> T:
...
def set(
self,
value: T,
) -> None:
...
It both produces and consumes T, so invariance is appropriate.
16. Variance in callable types
Function parameter types are contravariant; return types are covariant.
Expected callback:
from collections.abc import Callable
DogHandler = Callable[
[Dog],
Animal,
]
A function accepting any Animal and returning a Dog can satisfy that need:
def process_animal(
value: Animal,
) -> Dog:
...
Why?
- accepting
Animalis broad enough for everyDog; - returning
Dogis specific enough to be anAnimal.
This is a foundational variance example.
17. Type parameter defaults
Modern Python typing supports defaults for type parameters in appropriate versions and tooling.
Conceptually:
class Response[T = bytes]:
...
Before adopting newer syntax in a production library:
- confirm the minimum Python version;
- confirm type-checker support;
- confirm packaging metadata;
- document compatibility.
Avoid using the newest syntax merely for novelty when consumers use older Python versions.
18. Variadic generics
A tuple can preserve heterogeneous shape using a type variable tuple.
def passthrough[*Ts](
values: tuple[*Ts],
) -> tuple[*Ts]:
return values
Example:
result = passthrough(
(1, "hello", True)
)
The type retains the three element types.
Compatibility uses TypeVarTuple and Unpack.
Use cases:
- array-shape libraries;
- tuple transformations;
- strongly typed decorators;
- framework APIs preserving arbitrary parameter lists.
Most application code does not need variadic generics.
19. ParamSpec
ParamSpec preserves callable parameters.
from collections.abc import Callable
from typing import (
ParamSpec,
TypeVar,
)
P = ParamSpec("P")
R = TypeVar("R")
def traced(
function: Callable[P, R],
) -> Callable[P, R]:
...
The decorated function retains its full signature.
A plain type variable cannot represent an arbitrary parameter list.
ParamSpec is especially useful for:
- decorators;
- callback adapters;
- middleware;
- dependency injection wrappers;
- task scheduling.
20. Concatenate
A decorator may add a leading parameter:
from typing import Concatenate
def with_context[
**P,
R,
](
function: Callable[
Concatenate[
RequestContext,
P,
],
R,
],
) -> Callable[P, R]:
...
The wrapper supplies RequestContext; callers supply the remaining parameters.
This is advanced typing. Keep runtime implementation and static contract aligned.
21. Generic factory methods
from typing import Self
class Model:
@classmethod
def from_mapping(
cls,
data: dict[str, object],
) -> Self:
instance = cls()
...
return instance
Self is often simpler than introducing a type parameter bound to the class.
Use a generic type parameter when the relationship spans multiple values or independent types.
Use Self when the return value is the current class or subclass.
22. Runtime behavior of generics
box = Box[int](10)
Runtime generic metadata may exist, but Python generally does not enforce int.
Box[int](
"not an integer"
)
may still construct an object.
Do not build validation assumptions around type parameter syntax.
If runtime type enforcement is required:
- validate explicitly;
- use a schema library;
- use domain constructors;
- preserve type objects deliberately.
23. Type erasure and introspection
Python's runtime representation is not identical to TypeScript erasure.
You can often inspect:
from typing import (
get_args,
get_origin,
)
annotation = list[int]
print(
get_origin(annotation)
)
print(
get_args(annotation)
)
But static type parameters are not a universal runtime validation system.
Generic runtime introspection becomes complex with:
- aliases;
- forward references;
- protocols;
- unions;
- annotations;
- substitutions;
- inheritance.
Use established libraries rather than implementing a complete validator casually.
24. TypeScript comparison
TypeScript:
function first<T>(
values: T[]
): T {
return values[0];
}
Python:
def first[T](
values: list[T],
) -> T:
return values[0]
Shared concepts:
- generic functions;
- generic classes;
- constraints or bounds;
- defaults;
- variance;
- callback relationships.
Differences:
- Python's runtime does not enforce specialization;
- Python mutable collections are treated invariantly by type checkers;
- Python supports structural protocols alongside nominal classes;
- Python has multiple checker implementations;
- modern syntax must match the project's minimum Python version.
25. Common mistakes
Generic with no relationship
def log[T](
value: T,
) -> None:
...
If T appears only once and no relationship is preserved, object may be enough.
Union instead of constrained generic
This may allow incompatible combinations.
Mutable covariance assumption
list[Dog] is not list[Animal].
Universal repository
The abstraction may erase important domain differences.
Overloads when a generic relationship is simple
Prefer the simpler contract.
Generics as runtime validation
They are primarily static contracts.
Latest syntax without compatibility planning
Document the minimum Python version.
26. English vocabulary
| Term | Meaning |
|---|---|
| generic | type parameterized by another type |
| type parameter | symbolic type used in a generic contract |
| bound | upper type limit allowing subtypes |
| constraint | fixed set of allowed alternatives |
| invariant | generic type does not follow subtype direction |
| covariant | generic subtype direction matches contained type |
| contravariant | generic subtype direction reverses |
| producer | abstraction returning values |
| consumer | abstraction accepting values |
| specialization | generic type supplied with concrete arguments |
Useful sentences:
- “The generic preserves the relationship between input and output.”
- “A constraint prevents mixing strings and bytes.”
- “The mutable container is invariant because callers can insert values.”
- “The read-only sequence can be covariant.”
- “The callback parameter is contravariant and the return value covariant.”
- “The abstraction introduces a type parameter without preserving any useful relationship.”
27. Speaking task
Explain for twelve minutes:
Why
list[Dog]is not a subtype oflist[Animal].
Then explain covariance and contravariance with producer and consumer examples.
28. Writing task
Write a 500-word design review of a generic repository interface used for users, invoices, audit events, configuration, and binary files.
29. Exercises
Exercise 1
Write a generic last function.
Exercise 2
Write a constrained generic joining either strings or bytes without mixing them.
Exercise 3
Design a covariant read-only Source.
Exercise 4
Design a contravariant Sink.
Exercise 5
Type a decorator preserving arbitrary parameters and return type.
Exercise 6
Use overloads for a function returning text for bytes input and unchanged text for string input.
30. Complete solutions
Solution 1
def last[T](
values: list[T],
) -> T:
if not values:
raise ValueError(
"Expected at least one value"
)
return values[-1]
A more flexible input might be Sequence[T].
Solution 2
def join_pair[
T: (str, bytes)
](
left: T,
right: T,
) -> T:
return left + right
A type checker should reject mixing str and bytes.
Solution 3
from typing import Protocol
class Source[T](
Protocol
):
def get(self) -> T:
...
For explicit legacy variance:
from typing import TypeVar
T_co = TypeVar(
"T_co",
covariant=True,
)
A source only produces values.
Solution 4
from typing import Protocol
class Sink[T](
Protocol
):
def put(
self,
value: T,
) -> None:
...
A sink only consumes values. Type-checker variance inference and syntax support should be verified for the project's target version. Library code may use an explicit contravariant TypeVar.
Solution 5
from collections.abc import Callable
from functools import wraps
from typing import (
ParamSpec,
TypeVar,
)
P = ParamSpec("P")
R = TypeVar("R")
def logged(
function: Callable[P, R],
) -> Callable[P, R]:
@wraps(function)
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
print(
function.__name__
)
return function(
*args,
**kwargs,
)
return wrapper
Solution 6
from typing import overload
@overload
def normalize(
value: str,
) -> str:
...
@overload
def normalize(
value: bytes,
) -> str:
...
def normalize(
value: str | bytes,
) -> str:
if isinstance(
value,
bytes,
):
return value.decode(
"utf-8"
)
return value
31. Chapter checkpoint
You should now be able to explain:
- generic relationships;
- modern and compatibility syntax;
- generic classes and aliases;
- bounds;
- constraints;
- unions versus constrained generics;
- overloads;
- invariance;
- covariance;
- contravariance;
- callable variance;
- variadic generics and
ParamSpec; - static versus runtime generic behavior.