Skip to content

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:

# snippet - illustrative pattern
def get_users() -> list:  # What kind of list?
    ...

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:

# snippet - illustrative pattern
def get_users() -> list[User]:  # A list of Users
    ...

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:

  1. Unsubscripted: The generic without type arguments (list, Dict)
  2. 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:

# snippet - illustrative
print(type_var_node.default)  # TypeNode or None

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:

See also