Generics and variance¶
This page explains how typing-graph represents generic types, type parameters, and variance. Understanding how typing-graph models generics enables you to build type introspection tools that work with parameterized types.
Why generics exist¶
Before generics, container types couldn't express what they contained. A function returning list told you nothing about the elements. You'd write:
Callers had to trust documentation, runtime checks, or experimentation to learn that this returned a list of User objects. Static type checkers couldn't help because they lacked the necessary information.
Generics solve this by letting types carry parameters:
Now the type annotation is both human-readable and machine-checkable. The type checker can verify that code using the returned list treats elements as User objects.
typing-graph models this parameterization explicitly. When you inspect list[User], you get a SubscriptedGenericNode that separately captures the generic origin (list) and its type arguments (User). This structure enables tools to answer questions like "what does this list contain?" or "is this a mapping type?"
What are generics?¶
Generics let you parameterize types with other types. Instead of writing separate container classes for each element type, you write one generic class that works with any type:
from typing import Generic, TypeVar
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")
Python's built-in types like list, dict, and set are all generic.
Type parameters¶
Type parameters are placeholders that get filled in when you use a generic type. Python has three kinds:
Using TypeVar for single types¶
TypeVar represents a single type:
from typing import TypeVar
T = TypeVar('T') # Can be any type
N = TypeVar('N', bound=int) # Must be int or subtype
S = TypeVar('S', str, bytes) # Must be exactly str or bytes
typing-graph represents these as TypeVarNode:
from typing import TypeVar
from typing_graph import inspect_type
T = TypeVar('T', bound=int)
node = inspect_type(T)
print(node.name) # T
print(node.bound) # ConcreteNode(cls=int)
print(node.constraints) # ()
print(node.variance) # Variance.INVARIANT
The ParamSpec type¶
ParamSpec (PEP 612) captures the entire parameter list of a callable:
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec('P')
R = TypeVar('R')
def decorator(f: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return wrapper
typing-graph represents these as ParamSpecNode:
from typing import ParamSpec
from typing_graph import inspect_type
P = ParamSpec('P')
node = inspect_type(P)
print(type(node).__name__) # ParamSpecNode
print(node.name) # P
Using TypeVarTuple for variadic types¶
TypeVarTuple (PEP 646) captures a variable number of types:
# snippet - PEP 646 unpack syntax requires Python 3.11+
from typing import TypeVarTuple
Ts = TypeVarTuple('Ts')
def concat(*args: *Ts) -> tuple[*Ts]:
return args
typing-graph represents these as TypeVarTupleNode:
from typing_extensions import TypeVarTuple
from typing_graph import inspect_type
Ts = TypeVarTuple('Ts')
node = inspect_type(Ts)
print(type(node).__name__) # TypeVarTupleNode
print(node.name) # Ts
Comparing unsubscripted and subscripted generics¶
A generic type can appear in two forms:
- Unsubscripted: The generic without type arguments (
list,Dict) - Subscripted: The generic with type arguments applied (
list[int],Dict[str, Any])
typing-graph represents these as GenericTypeNode and SubscriptedGenericNode respectively:
from typing_graph import inspect_type, GenericTypeNode, SubscriptedGenericNode
# Unsubscripted generic
node = inspect_type(list)
print(type(node).__name__) # GenericTypeNode
print(node.cls) # <class 'list'>
# Subscripted generic
node = inspect_type(list[int])
print(type(node).__name__) # SubscriptedGenericNode
print(node.origin) # GenericTypeNode for list
print(node.args) # (ConcreteNode(cls=int),)
Nested generics¶
You can nest generics arbitrarily:
from typing_graph import inspect_type
# dict[str, list[int]]
node = inspect_type(dict[str, list[int]])
print(type(node).__name__) # SubscriptedGenericNode
print(len(node.args)) # 2
key_node = node.args[0] # ConcreteNode(cls=str)
value_node = node.args[1] # SubscriptedGenericNode for list[int]
Variance¶
Variance describes how subtyping of type parameters relates to subtyping of the generic type itself. This concept often confuses newcomers to type systems, but it has important practical implications.
Why variance matters
Consider a function that processes a list of animals:
# snippet - illustrative pattern
def feed_animals(animals: list[Animal]) -> None:
for animal in animals:
animal.eat()
Can you pass a list[Cat] to this function? Intuitively, yes, since cats are animals. But what if the function modifies the list?
# snippet - illustrative pattern
def add_animal(animals: list[Animal]) -> None:
animals.append(Dog()) # Adds a dog to the list
Now passing list[Cat] would be a type error: you'd have a dog in your list of cats.
Variance rules encode these safety considerations. Understanding variance helps you design generic types correctly and understand why the type checker rejects certain code.
Invariance (default)¶
With an invariant type parameter, Box[Cat] is neither a subtype nor supertype of Box[Animal]:
from typing import TypeVar, Generic
T = TypeVar('T') # Invariant by default
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
# Box[Cat] is NOT compatible with Box[Animal]
Covariance¶
With a covariant type parameter, if Cat is a subtype of Animal, then Box[Cat] is a subtype of Box[Animal]:
from typing import TypeVar, Generic
T_co = TypeVar('T_co', covariant=True)
class ReadOnlyBox(Generic[T_co]):
def __init__(self, value: T_co) -> None:
self._value = value
def get(self) -> T_co:
return self._value
# ReadOnlyBox[Cat] IS compatible with ReadOnlyBox[Animal]
Covariance is safe when the type parameter only appears in output positions (return types).
Contravariance¶
With a contravariant type parameter, if Cat is a subtype of Animal, then Handler[Animal] is a subtype of Handler[Cat]:
from typing import TypeVar, Generic
T_contra = TypeVar('T_contra', contravariant=True)
class Handler(Generic[T_contra]):
def handle(self, value: T_contra) -> None:
...
# Handler[Animal] IS compatible with Handler[Cat]
Contravariance is safe when the type parameter only appears in input positions (parameter types).
Checking variance¶
typing-graph captures variance on TypeVarNode:
from typing import TypeVar
from typing_graph import inspect_type, Variance
T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)
T = TypeVar('T')
print(inspect_type(T_co).variance) # Variance.COVARIANT
print(inspect_type(T_contra).variance) # Variance.CONTRAVARIANT
print(inspect_type(T).variance) # Variance.INVARIANT
Automatic variance inference in PEP 695¶
PEP 695 introduced automatic variance inference. When you use the type statement, Python infers variance from how you use the type parameter:
# snippet - PEP 695 syntax requires Python 3.12+
type ReadOnlyBox[T] = ... # Variance inferred from usage
typing-graph captures this with the infer_variance flag:
# snippet - illustrative
# For PEP 695 type parameters
print(type_var_node.infer_variance) # True if auto-inferred
Why automatic variance is significant
Before PEP 695, specifying variance correctly was a source of friction. You had to understand variance well enough to choose covariant=True or contravariant=True, and getting it wrong produced confusing type errors.
Automatic inference removes this burden for most cases. The type checker examines how you use the type parameter: only in return positions (covariant), only in parameter positions (contravariant), or both (invariant). This matches how other languages like Kotlin and C# handle variance.
typing-graph's infer_variance flag lets you distinguish between explicitly declared variance and inferred variance, which can matter for documentation generation or migration tooling.
Type parameter bounds and constraints¶
Bounds¶
A bound restricts a type parameter to a type or its subtypes:
from typing import TypeVar
T = TypeVar('T', bound=int) # T must be int or a subtype
def double(x: T) -> T:
return x * 2 # Works because int supports *
from typing import TypeVar
from typing_graph import inspect_type
T = TypeVar('T', bound=int)
node = inspect_type(T)
print(node.bound) # ConcreteNode(cls=int)
Constraints¶
Constraints restrict a type parameter to specific types (not subtypes):
from typing import TypeVar
T = TypeVar('T', str, bytes) # T must be exactly str or bytes
def process(data: T) -> T:
return data.upper() # Works because both str and bytes have upper()
from typing import TypeVar
from typing_graph import inspect_type
T = TypeVar('T', str, bytes)
node = inspect_type(T)
print(node.constraints) # (ConcreteNode(cls=str), ConcreteNode(cls=bytes))
Type parameter defaults (PEP 696)¶
Python 3.13 introduces default values for type parameters:
# snippet - PEP 696 requires Python 3.13+
from typing import TypeVar
T = TypeVar('T', default=int)
class Container(Generic[T]):
...
# Container() is equivalent to Container[int]
typing-graph captures defaults on all type parameter nodes:
Node summary¶
| Concept | Node type | Key attributes |
|---|---|---|
| Type variable | TypeVarNode |
name, variance, bound, constraints, default |
| Parameter spec | ParamSpecNode |
name, default |
| Variadic type | TypeVarTupleNode |
name, default |
| Unsubscripted generic | GenericTypeNode |
cls, type_params |
| Subscripted generic | SubscriptedGenericNode |
origin, args |
Practical application¶
Now that you understand generics, apply this knowledge:
- Traverse generic types with Walking the type graph
- Work with generic aliases in Type aliases
- Inspect functions with generic parameters in Inspecting functions
See also¶
- Type aliases - How typing-graph represents generic type aliases
- Architecture overview - How generic inspection fits into the design
- Type variable - Glossary definition
- PEP 484 - Type hints specification
- PEP 612 - ParamSpec
- PEP 646 - TypeVarTuple
- PEP 695 - Type parameter syntax
- PEP 696 - Type parameter defaults