Variadic Generics with TypeVarTuple (PEP 646)

A TypeVarTuple is a single type variable that stands for an arbitrary number of types at once. Where an ordinary TypeVar captures one type, a TypeVarTuple captures a whole sequence — enabling generic classes and functions whose arity is not fixed, such as a tensor typed by its shape or a zip-like function typed by each of its inputs.

Introduced by PEP 646 and shipped in Python 3.11, variadic generics fill a long-standing gap: before them, there was no way to write a Array[Height, Width] whose number of dimensions varied, nor to preserve every element type of a heterogeneous tuple through a transformation. This section covers the syntax, the Unpack/*Ts mechanics, and the current state of analyzer support.

How a TypeVarTuple expands A single TypeVarTuple named Ts binds to a sequence of concrete types, which then unpack in position inside a tuple annotation, optionally with a fixed prefix element. TypeVarTuple Ts = *Ts binds to a sequence (int, str, bytes) tuple[int, *Ts] tuple[int, int, str, bytes] fixed prefix + unpacked Ts
A single TypeVarTuple binds to a sequence of types, which unpack in place inside a tuple annotation.

The core syntax

You declare a TypeVarTuple and splice it into a tuple with the unpack star. On Python 3.11 the classic spelling uses TypeVarTuple from typing; the unpacked position can be written either as *Ts (the star syntax) or Unpack[Ts] (the explicit form). Both mean the same thing — *Ts is pure syntactic sugar for Unpack[Ts], and a checker treats them as identical. The star spelling reads better inside a tuple[...], while Unpack[Ts] is the only option on Python 3.10 and earlier where the star-in-subscript grammar does not parse, so libraries that still support 3.10 through typing_extensions tend to use Unpack everywhere.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeVarTuple, Unpack

Ts = TypeVarTuple("Ts")

def move_first_to_last(t: tuple[int, *Ts]) -> tuple[*Ts, int]:
    first, *rest = t
    return (*rest, first)

reveal_type(move_first_to_last((1, "a", b"z")))  # tuple[str, bytes, int]

The *Ts inside tuple[int, *Ts] says “an int, then however many further elements Ts stands for.” The return annotation reorders them, and the checker tracks every element type through the call.

*Ts and Unpack[Ts] are equivalent spellings Both the star Ts star-syntax and the explicit Unpack Ts form resolve to the same unpacked TypeVarTuple that a checker sees. tuple[int, *Ts] star syntax (3.11+) tuple[int, Unpack[Ts]] one unpacked Ts int, then Ts elements
The star form and Unpack[Ts] are interchangeable; a checker resolves both to the same unpacked TypeVarTuple.

The elementwise tracking is what makes variadics useful: after move_first_to_last((1, "a", b"z")) the checker knows the result is exactly tuple[str, bytes, int], not a lossy tuple[object, ...]. A TypeVarTuple also has an implicit bound of tuple[Any, ...] and cannot carry a bound= or constraints= argument the way an ordinary TypeVar can — its whole job is to stand for a positional sequence, so per-element constraints are simply not expressible. If you need every captured element to share a type, that is a homogeneous tuple[T, ...] with a normal TypeVar, not a variadic. An unbound Ts used in a value position (for example def f() -> Ts) is an error: mypy reports [valid-type] and pyright reportInvalidTypeForm, because a bare TypeVarTuple has no meaning outside an unpacked context.

The unpack star is legal in three positions and nowhere else: inside a tuple[...] subscript, inside a Callable[[...], R] parameter list, and on a *args parameter. A single Unpack can also splice a concrete fixed-length tuple type — tuple[int, *tuple[str, bool]] is tuple[int, str, bool] — which is how prefix and suffix elements compose with the variadic. What you cannot do is subscript a TypeVarTuple (Ts[0] is meaningless) or use it as a Union member; the checker only ever expands it positionally. Because the star form is grammar-level sugar, error messages from mypy and pyright normalise everything back to Unpack[Ts], so a diagnostic mentioning Unpack may point at a line you wrote with a plain star.

PEP 695 form on 3.12

Python 3.12’s PEP 695 type parameter syntax lets you declare the variadic parameter inline with [*Ts], dropping the module-level TypeVarTuple object entirely. The [*Ts] in the type-parameter list is the declaration; the *Ts used inside the signature is a use of that already-scoped name.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
def stack[*Ts](*rows: *Ts) -> tuple[*Ts]:
    return rows

reveal_type(stack(1, "a", 3.0))  # tuple[int, str, float]

Here [*Ts] scopes the variadic to the function, and *rows: *Ts types the *args so that each positional argument keeps its own type in the returned tuple. This is the recommended spelling on 3.12+; the TypeVarTuple(...) object form remains for 3.11 and typing_extensions backports.

Module-level TypeVarTuple versus inline PEP 695 declaration The 3.11 form needs a separate Ts equals TypeVarTuple line, while the 3.12 PEP 695 form declares the variadic inline in the function's bracket list. 3.11 — explicit object Ts = TypeVarTuple("Ts") def stack(*rows: *Ts) -> tuple[*Ts]: ... module-level binding needed 3.12 — PEP 695 inline def stack[*Ts](*rows: *Ts) -> tuple[*Ts]: ... scoped to the function
PEP 695 folds the separate TypeVarTuple("Ts") binding into the function's inline [*Ts] parameter list.

Under the hood the two spellings converge — a PEP 695 [*Ts] still constructs a TypeVarTuple object, it is just created implicitly and scoped lexically rather than assigned to a module global. The practical differences are ergonomic: the inline form avoids name-mismatch bugs where a signature accidentally references a Ts from another module, and it reads closer to how ordinary generics look in other languages. It also composes with PEP 695’s other inline parameters, so class Tensor[DType, *Shape] declares a leading ordinary type parameter and a trailing variadic in one bracket list without any module-level TypeVar/TypeVarTuple bindings at all.

The two forms are not interchangeable in one scope: a PEP 695 [*Ts] creates a fresh type parameter local to that function or class, whereas a module-level Ts = TypeVarTuple("Ts") can be shared across several signatures. Do not mix them for the same logical parameter — reusing a global Ts inside a def f[*Ts](...) shadows the global with a new inline variable and usually is not what you want. A subtle runtime note: the PEP 695 form is evaluated lazily, so stack.__type_params__ exposes the TypeVarTuple object the interpreter created for you, and typing.get_type_hints still resolves the annotations. On 3.11, PEP 695 syntax is a SyntaxError; you must use the object form or import TypeVarTuple from typing_extensions and keep the classic spelling. Both mypy and pyright accept the PEP 695 variadic on their current releases, but require you to actually run under a parser that understands it — mypy will flag [syntax] if you feed 3.12 syntax while targeting --python-version 3.11.

Shape-typed classes: the motivating case

The headline use for variadic generics is typing array and tensor shapes. A class Array[*Shape] is generic over an arbitrary number of dimension types, so a 2-D array and a 3-D array are distinct static types and a mismatched operation is a type error rather than a silent runtime bug. PEP 646 was in fact motivated directly by the numeric-Python ecosystem — NumPy, TensorFlow, and JAX all wanted to encode rank in the type — and the PEP’s own examples use exactly this Array[*Shape] framing.

# Python 3.12+, checked with pyright 1.1.370
from typing import NewType

Height = NewType("Height", int)
Width = NewType("Width", int)
Channels = NewType("Channels", int)

class Array[*Shape]:
    def __init__(self, *shape: *Shape) -> None:
        self._shape = shape

    @property
    def shape(self) -> tuple[*Shape]:
        return self._shape

image: Array[Height, Width, Channels] = Array(Height(1080), Width(1920), Channels(3))
reveal_type(image.shape)  # tuple[Height, Width, Channels]

The number of dimensions is encoded in the type itself, so a function that expects Array[Height, Width] rejects a three-dimensional Array before any numeric code runs.

One variadic class, many distinct ranks The generic Array over star Shape specializes into separate types for a two-dimensional and a three-dimensional array, which are not interchangeable. Array[*Shape] generic over rank Array[Height, Width] rank 2 — a matrix Array[H, W, Channels] rank 3 — an image
One Array[*Shape] definition produces incompatible static types per rank, so a rank-2 value cannot satisfy a rank-3 parameter.

Using NewType for Height and Width rather than bare int is what makes the shape self-documenting and prevents accidentally swapping axes — Array[Width, Height] is a different type from Array[Height, Width] even though both erase to tuples of int. The pattern combines cleanly with a fixed prefix or suffix: class Batch[Batch_, *Shape] pins a leading batch dimension while leaving the spatial dimensions variadic. One real limitation is arithmetic on dimensions — PEP 646 gives you rank polymorphism but not size arithmetic, so you cannot express “concatenating an Array[N] and Array[M] yields Array[N+M]”; that needs literal integer type arithmetic which Python’s type system does not have. For shape checking that goes beyond rank, teams typically pair these annotations with a runtime library and reserve the static types for catching gross rank mismatches early.

Runtime vs static analysis A TypeVarTuple carries no runtime shape checking. Array(Height(1080), Width(1920)) stores an ordinary tuple; Python never verifies that the values match *Shape. If you pass three values where the annotation says two, the program runs fine and only a static checker flags it. Variadic generics document and enforce shape *at analysis time*, not at execution.

Analyzer support today

Support is uneven, so pin versions and test both checkers if you rely on variadics. Pyright has had strong, complete PEP 646 support for years, including prefix/suffix unpacking and [*Ts] scoping. mypy shipped TypeVarTuple support later and has steadily improved through the 1.x line; older releases mishandled a fixed suffix such as tuple[*Ts, int] or reported spurious [misc] errors on *args: *Ts. On current mypy (1.10+) the common patterns work, but edge cases around bounded variadics and nested unpacks can still diverge from pyright. See pyright vs mypy for how their solvers differ.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeVarTuple, Generic

Ts = TypeVarTuple("Ts")

class Batch(Generic[*Ts]):  # a single *Ts is allowed; a second one is not
    ...

# class Bad(Generic[*Ts, *Us]): ...  # error: pyright reportGeneralTypeIssues

Exactly one TypeVarTuple may appear per generic — you can combine it with ordinary TypeVars, but not with a second variadic, because the checker could not decide where one sequence ends and the next begins.

PEP 646 feature support: pyright versus mypy 1.10+ Pyright fully supports plain forwarding, fixed prefix, fixed suffix, and nested unpacks, while current mypy is solid on the first three and can still diverge on nested unpacks. pyright mypy 1.10+ forwarding *args: *Ts full full fixed prefix tuple[int, *Ts] full full fixed suffix tuple[*Ts, int] full fixed in 1.x nested unpacks full can diverge
Pyright covers the full PEP 646 surface; current mypy matches it except on the deepest nested-unpack cases.

Concretely, a practical policy is: develop against pyright (which follows the specification most closely and updates fastest), then run mypy in CI with a pinned version and a note of any known-diverging pattern. When a variadic annotation type-errors under only one checker, reduce it to the smallest reproducer and check whether it is one of the known-hard shapes — fixed suffix, bounded element, or a TypeVarTuple interleaved with several TypeVars — before assuming your annotation is wrong. The typing_extensions backport matters here too: importing TypeVarTuple and Unpack from typing_extensions rather than typing gives you the latest agreed semantics even on older interpreters, and both checkers treat the typing_extensions names as equivalent to the stdlib ones. For projects still on Python 3.8–3.10, typing_extensions is the only way to use variadics at all, since typing.TypeVarTuple did not exist before 3.11.

Common mistakes

Most variadic bugs cluster around four confusions: how many TypeVarTuples a signature may hold, remembering the unpack star, trusting old tooling on suffixes, and expecting the annotation to do anything at runtime. The failure modes below name the exact checker diagnostics so you can recognise them in CI output.

Four variadic pitfalls and the errors they raise Two TypeVarTuples give a misc error, a missing star gives valid-type, an old mypy suffix mis-resolves, and a runtime shape mismatch raises no static error at all. Generic[*Ts, *Us] mypy [misc] / reportGeneralTypeIssues tuple[int, Ts] [valid-type] / reportInvalidTypeForm tuple[*Ts, int] on old mypy mis-resolves — upgrade mypy wrong shape at runtime no static error — add assert
Each variadic pitfall maps to a distinct outcome — three are static diagnostics, the runtime one is caught only by an explicit length check.
  • Two TypeVarTuples in one class or signature: Generic[*Ts, *Us] is rejected — mypy emits [misc] and pyright reportGeneralTypeIssues. The checker cannot split one flat argument list into two variable-length sequences, so use a single variadic plus fixed prefix/suffix elements (tuple[int, *Ts, str]) instead.
  • Forgetting the star: writing tuple[int, Ts] instead of tuple[int, *Ts] treats Ts as a bare name; mypy reports [valid-type] / pyright reportInvalidTypeForm because an unpacked TypeVarTuple is required in that position. The same applies to *args: Ts — it must be *args: *Ts.
  • Relying on 3.11 mypy for suffixes: tuple[*Ts, int] and tuple[int, *Ts, str] were buggy in early mypy releases. If a fixed suffix mis-resolves, upgrade mypy before assuming your annotation is wrong; pyright resolved these from the start, so a cross-check there quickly tells you whether the annotation or the tool is at fault.
  • Expecting runtime enforcement: shape mismatches never raise on their own — *Shape is erased, so Array(Height(3)) where two dimensions were annotated runs fine and only a later unpack such as r, c = self._shape raises ValueError. Add explicit assert len(shape) == 2 guards where runtime safety matters.
  • Reaching for a variadic when arity is fixed: if every instance has the same number of parameters, ordinary TypeVars or a ParamSpec are simpler and far better supported than *Ts. Reserve variadics for genuinely variable arity.

FAQ

When do I need a TypeVarTuple instead of a plain TypeVar? Reach for a TypeVarTuple only when the number of type parameters varies — heterogeneous tuples, zip/apply-style helpers, and shape-typed arrays. If every instance has the same fixed arity, ordinary TypeVars or a ParamSpec (for callable parameter lists) are simpler and better supported.

*How does Ts differ from ParamSpec’s P? A TypeVarTuple captures a sequence of positional type arguments that live inside a tuple; a ParamSpec captures an entire call signature, including keyword parameters, for forwarding to a Callable. They solve different problems and can even be combined in advanced adapter code.

Can I bound a TypeVarTuple to a single element type? Not directly — a TypeVarTuple has no bound=. If you need every element to share a type, a homogeneous tuple[T, ...] with a normal TypeVar expresses that better than a variadic.

Back to Advanced Typing Patterns & Generics