Typing Variadic Tuples with TypeVarTuple
TL;DR — Use a TypeVarTuple to type a function or class whose number of type parameters is not fixed. Capture the incoming positional types with *args: *Ts and echo them back with tuple[*Ts] or Callable[[*Ts], R]. You can pin a fixed element before or after the variadic — tuple[int, *Ts, str] — and the checker tracks every element type through the call.
An ordinary generic function fixes its arity: def pair(a: T, b: S) -> tuple[T, S] always takes exactly two arguments. When you want the same precision but for a variable number of arguments — a forwarding helper, a zip-style combiner, or a shape-typed container — you need PEP 646’s variadic generics, part of Advanced Typing Patterns & Generics. This page walks the concrete patterns: capturing *args, adding a fixed prefix and suffix, and building an Array class typed by its shape.
Forwarding *args with *Ts
The canonical variadic function captures each positional argument’s type and threads it through. Type the *args parameter as *args: *Ts, and the checker binds Ts to the exact sequence you passed.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeVarTuple, TypeVar, Callable
Ts = TypeVarTuple("Ts")
R = TypeVar("R")
def apply(func: Callable[[*Ts], R], *args: *Ts) -> R:
return func(*args)
def build_label(count: int, name: str) -> str:
return f"{count}x {name}"
reveal_type(apply(build_label, 3, "widget")) # str
apply(build_label, "3", "widget") # mypy error: [arg-type] — "3" is not int
The Callable[[*Ts], R] annotation ties the callback’s parameter list to the very same *Ts that types *args. Pass an argument of the wrong type and mypy reports [arg-type] / pyright reportArgumentType, because the sequence bound by the arguments no longer matches the callback’s expected parameters.
Adding a fixed prefix and suffix
A TypeVarTuple need not consume the whole tuple. You can require an exact type at the front, the back, or both, and let *Ts absorb everything in between.
# Python 3.11+, checked with pyright 1.1.370
from typing import TypeVarTuple
Ts = TypeVarTuple("Ts")
# Every record starts with a primary-key int and ends with a version str.
def wrap_record(pk: int, *body: *Ts, version: str = "v1") -> tuple[int, *Ts, str]:
return (pk, *body, version)
reveal_type(wrap_record(7, "alice", True)) # tuple[int, str, bool, str]
The return type tuple[int, *Ts, str] reconstructs the shape precisely: the leading int, each captured middle element, then the trailing str. This prefix/suffix support is where analyzers historically diverged — pyright handled it from the start, while early mypy releases mis-resolved a trailing element. On mypy 1.10+ it works; on older versions, upgrade before trusting a suffix.
A shape-typed Array class
Classes take a variadic parameter too. class Array[*Shape] is generic over any number of dimension types, so arrays of different rank are distinct static types. Use domain NewType aliases for the dimensions to keep the shapes readable.
# Python 3.12+, checked with pyright 1.1.370
from typing import NewType
Rows = NewType("Rows", int)
Cols = NewType("Cols", int)
class Array[*Shape]:
def __init__(self, *dims: *Shape) -> None:
self._dims = dims
def transpose(self: "Array[Rows, Cols]") -> "Array[Cols, Rows]":
r, c = self._dims
return Array(c, r)
grid: Array[Rows, Cols] = Array(Rows(3), Cols(4))
reveal_type(grid.transpose()) # Array[Cols, Rows]
Because the rank lives in the type, a function annotated for Array[Rows, Cols] will reject a three-dimensional array, and transpose can promise a swapped shape that the checker verifies.
*Shape parameter is erased at runtime — Array(Rows(3), Cols(4)) just stores a two-element tuple, and Python performs no rank check. If transpose is called on an array whose real length is not two, the r, c = self._dims unpack raises ValueError at runtime even though the static types looked consistent. Add explicit length assertions where runtime safety matters.
mypy vs pyright behavior
For plain forwarding (*args: *Ts → tuple[*Ts]) both checkers agree on current versions. Divergence appears in three places: a fixed suffix after the variadic, bounded elements, and mixing a TypeVarTuple with several ordinary TypeVars in one signature. Pyright’s solver resolves these consistently; mypy’s has caught up for the common cases but can still emit a spurious [misc] on deeply nested unpacks. If a variadic annotation behaves differently under the two tools, treat pyright’s result as the reference and check mypy’s issue tracker for the specific pattern.
Common mistakes
- Bare
Tswithout the star:def f(*args: Ts)instead of*args: *Tsis invalid — mypy reports[valid-type], pyrightreportInvalidTypeForm. The unpack star is mandatory in argument and tuple positions. - Two variadics in one signature:
def f(*a: *Ts, **b: *Us)cannot be resolved; the checker emits[misc]/reportGeneralTypeIssues. Keep oneTypeVarTupleper signature. - Unpacking a variable-length result blindly:
r, c = self._dimsassumes exactly two elements; the static type may say so while runtime length differs, giving aValueError. Guard withassert len(self._dims) == 2when unpacking. - Assuming old mypy handles suffixes:
tuple[*Ts, int]mis-resolved before mypy 1.x matured — verify your version rather than rewriting a correct annotation.
FAQ
Can a function take both leading regular TypeVars and a TypeVarTuple?
Yes. A single TypeVarTuple may coexist with any number of ordinary TypeVars in the same signature — for example def f(x: T, *rest: *Ts) -> tuple[T, *Ts]. The only rule is that at most one variadic appears.
*How do I type args when every argument shares one type?
Use a normal TypeVar with *args: T, which yields a homogeneous tuple[T, ...]. Reserve *Ts for heterogeneous arguments whose individual types you want to preserve separately.