Zum Hauptinhalt springen

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 Animal is broad enough for every Dog;
  • returning Dog is specific enough to be an Animal.

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

TermMeaning
generictype parameterized by another type
type parametersymbolic type used in a generic contract
boundupper type limit allowing subtypes
constraintfixed set of allowed alternatives
invariantgeneric type does not follow subtype direction
covariantgeneric subtype direction matches contained type
contravariantgeneric subtype direction reverses
producerabstraction returning values
consumerabstraction accepting values
specializationgeneric 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 of list[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:

  1. generic relationships;
  2. modern and compatibility syntax;
  3. generic classes and aliases;
  4. bounds;
  5. constraints;
  6. unions versus constrained generics;
  7. overloads;
  8. invariance;
  9. covariance;
  10. contravariance;
  11. callable variance;
  12. variadic generics and ParamSpec;
  13. static versus runtime generic behavior.