Python TypeVarTuple and Variadic Generics Explained
Python 3.11 introduced typing.TypeVarTuple via PEP 646
to bring variadic generics to Python's type system. Unlike standard type
variables that represent a single concrete type, a
TypeVarTuple represents an arbitrary number of types
grouped together. This article explains the purpose of
TypeVarTuple, the limitations of earlier type annotations
that necessitated its introduction, and practical examples of how it
enables precise typing for multidimensional arrays and heterogeneous
tuples.
The Problem Before PEP 646
Prior to PEP 646, standard TypeVar constructs were
limited to a single type parameter. While Python supported arbitrary
numbers of homogeneous elements using tuple[T, ...], it
lacked a mechanism to define generic types with an arbitrary number of
different types.
This created significant limitations in numerical computing and data science libraries like NumPy, PyTorch, and TensorFlow. In these frameworks, array shapes are fundamental to type safety, but the type system could not represent an array with an arbitrary number of typed dimensions. Developers and stub authors had to rely on cumbersome workarounds, such as defining multiple overloaded signatures for every possible dimension count:
# The old, limited approach requiring hardcoded arity
def process_1d(data: tuple[T1]) -> tuple[T1]: ...
def process_2d(data: tuple[T1, T2]) -> tuple[T1, T2]: ...
def process_3d(data: tuple[T1, T2, T3]) -> tuple[T1, T2, T3]: ...The Purpose of
TypeVarTuple
TypeVarTuple solves this limitation by acting as a
placeholder for a tuple of types of arbitrary length (zero or more).
When combined with the star unpacking operator (*), it
allows generic classes and functions to accept a variable number of type
arguments.
The primary purposes of TypeVarTuple include:
- Shape Typing for Tensors and Arrays: It enables multidimensional array libraries to annotate shapes statically, catching dimension mismatches and shape errors before runtime.
- Dynamic Tuple Manipulation: It allows functions that prepend, append, slice, or concatenate tuples to retain precise type information for every element.
- Cleaner Generic Signatures: It eliminates the need
for combinatorial
overloaddecorators previously used to approximate variadic behavior.
Basic Syntax and Usage
To use TypeVarTuple, import it from typing
(or typing_extensions for Python versions older than 3.11)
and unpack it within a generic context:
from typing import Generic, TypeVar, TypeVarTuple
Shape = TypeVarTuple("Shape")
class Array(Generic[*Shape]):
def __init__(self, shape: tuple[*Shape]) -> None:
self.shape = shape
# A 2D array of (Height, Width)
img: Array[int, int] = Array((1080, 1920))
# A 3D array of (Batch, Height, Width)
batch: Array[int, int, int] = Array((32, 1080, 1920))Starting in Python 3.12, PEP 695 introduced a more concise syntax for
generics, allowing type variable tuples to be defined directly using
*Ts:
class Array[*Shape]:
def __init__(self, shape: tuple[*Shape]) -> None:
self.shape = shapeTransforming Variadic Types
TypeVarTuple can be combined with regular
TypeVar instances to model functions that alter tuple
structure while preserving element types.
Prepending an Element
from typing import TypeVar, TypeVarTuple
T = TypeVar("T")
Ts = TypeVarTuple("Ts")
def prepend(item: T, rest: tuple[*Ts]) -> tuple[T, *Ts]:
return (item, *rest)
# Inferred type: tuple[str, int, float]
result = prepend("start", (1, 2.5))Slicing and Splitting
def remove_first(data: tuple[T, *Ts]) -> tuple[*Ts]:
return data[1:]
# Inferred type: tuple[int, bool]
tail = remove_first(("remove_me", 42, True))In these operations, the type checker accurately captures the removal or addition of individual types while maintaining the order and types of the remaining items.
Rules and Constraints
To ensure consistency and prevent ambiguity during type inference,
PEP 646 establishes several rules for TypeVarTuple:
- Single Instance per Type Argument List: An unpacked
TypeVarTuplecan appear at most once in a single type argument list. For example,tuple[*Ts, *Ts]is invalid because a type checker cannot determine where the first sequence ends and the second begins. - Unpacking Requirement: A
TypeVarTuplemust always be unpacked using the*operator when used inside a type argument list (e.g.,Generic[*Ts], notGeneric[Ts]). - Coexistence with Concrete Types: An unpacked
TypeVarTuplecan be placed anywhere relative to other types—at the start (tuple[*Ts, int]), middle (tuple[str, *Ts, int]), or end (tuple[str, *Ts]).