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 ordered sequence you passed — not a set, so (int, str) and (str, int) are different bindings.
# 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. Pass too few arguments — apply(build_label, 3) — and the callable expects two positionals it will not receive, so mypy raises [call-arg] and pyright reportCallIssue.
The unpack star and the explicit Unpack form are interchangeable: *args: *Ts means exactly *args: Unpack[Ts], and you may write either. On Python 3.11 both TypeVarTuple and Unpack come from typing; on 3.8–3.10 import them from typing_extensions. When no extra arguments are passed at all, Ts binds to the empty tuple () and the callable degrades to Callable[[], R] — a valid, well-typed case, not an error. Because the binding is purely positional, only the *args are captured; keyword arguments are invisible to a TypeVarTuple and belong to a ParamSpec instead, which captures an entire call signature.
On Python 3.12 the PEP 695 inline form drops the module-level objects entirely — def apply[*Ts, R](func: Callable[[*Ts], R], *args: *Ts) -> R scopes both parameters to the function header. The runtime semantics are identical; only the declaration site moves.
Beyond a TypeVarTuple, PEP 646 also lets you unpack a concrete unbounded tuple in the same position. def total(*args: *tuple[int, ...]) -> int accepts any number of ints and is equivalent to the older *args: int, while def head(*args: *tuple[int, *Ts]) -> tuple[int, *Ts] fixes the first argument as an int and lets Ts capture the rest. That *tuple[...] unpack is the only way to pin a leading positional type inside the same tuple binding, since a plain leading parameter — def f(first: int, *rest: *Ts) — keeps first outside the Ts sequence. Both mypy 1.10+ and pyright accept *tuple[X, ...] in argument and tuple positions; under from __future__ import annotations the annotation is stringized but the unpack semantics are unchanged, because the checker parses the string form identically.
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 — but a single tuple may contain only one unpack, so tuple[int, *Ts, str] is legal while tuple[*Ts, *Us] is not.
# 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. Note that version here is a genuine keyword-only parameter (it follows *body), so it is not absorbed into *Ts — only the positional *body binds the variadic. If the caller supplies a wrong prefix type, say wrap_record("7", "alice"), the mismatch is reported against the pk: int parameter as [arg-type] / reportArgumentType, independently of whatever *Ts captures.
When *body receives nothing, Ts binds to () and the return collapses cleanly to tuple[int, str] — the fixed ends simply meet in the middle. This prefix/suffix support is where analyzers historically diverged: pyright handled a trailing element from the start, while early mypy releases mis-resolved a suffix such as tuple[*Ts, int], sometimes emitting a spurious [misc] or dropping the trailing type. On mypy 1.10+ both prefix and suffix resolve correctly; on older versions, upgrade before trusting a suffix rather than rewriting a correct annotation. A suffix-only form like tuple[*Ts, int] (no prefix) is equally valid and follows the same rules.
The variadic middle can itself be an unbounded tuple rather than a TypeVarTuple: tuple[int, *tuple[str, ...], int] types a sequence that opens and closes with an int and holds any number of strs between them — ideal for a header/payload/trailer shape whose middle is homogeneous. PEP 646 permits at most one starred element in a tuple, whether that star is a *Ts or a *tuple[X, ...], so mixing the two — tuple[*Ts, *tuple[int, ...]] — is rejected for exactly the reason two TypeVarTuples are: the checker cannot decide where one open-ended run ends and the next begins. When a fixed element and the variadic collide on the same position, the fixed element always wins and *Ts binds to whatever remains.
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 — a 2-D Array[Rows, Cols] and a 3-D Array[Rows, Cols, Depth] are unrelated, and a function annotated for one rejects the other. 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 with [arg-type] / reportArgumentType, and transpose can promise a swapped shape that the checker verifies. The self: "Array[Rows, Cols]" annotation is doing real work here: it constrains transpose to arrays that are statically two-dimensional, so calling it on an Array[Rows, Cols, Depth] is a type error before any runtime unpack happens.
On Python 3.11 the same class is spelled with the explicit base and a module-level variadic — class Array(Generic[*Shape]) after Shape = TypeVarTuple("Shape") — which is equivalent to the PEP 695 class Array[*Shape] header on 3.12+. Only one variadic may appear in the class’s parameter list, but you can combine it with ordinary TypeVars: class Array[DType, *Shape] adds an element-type parameter in front of the shape. Pyright has supported class-level variadics fully for years; mypy gained solid support later in the 1.x line, so pin your mypy version if a generic Array misbehaves.
Ordering is significant: Array[Rows, Cols] and Array[Cols, Rows] are distinct types even though Rows and Cols are both NewTypes over int, because the checker compares the shape element by element — which is precisely what turns a mis-transposed call into a static error. Remember that a NewType is not a runtime subclass: Rows(3) returns the plain int 3, so the Rows/Cols distinction exists only for the checker, and passing a bare 3 where Rows is expected is an [arg-type] / reportArgumentType error under both tools. If a dimension should accept any int, drop the NewType and parameterise the shape over plain ints — at the cost of losing the ability to catch a swapped-axis bug.
*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 concentrates in three areas: a fixed suffix after the variadic, bounded or nested unpacks, 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 or a fixed suffix under older releases. 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 — see pyright vs mypy for how their inference engines differ more broadly.
# Python 3.11+
from typing import TypeVarTuple
Ts = TypeVarTuple("Ts")
def suffix(*args: *Ts) -> tuple[*Ts, int]:
return (*args, 0)
# pyright 1.1.370: reveal_type -> tuple[str, bool, int]
# mypy < 1.x: occasionally lost the trailing int or raised [misc]
reveal_type(suffix("a", True))
The practical rule: pin both checkers’ versions in CI, run the same file through each, and compare reveal_type output on any signature that combines a variadic with a fixed suffix or an ordinary TypeVar. A green result on one tool is not proof the annotation is portable, especially across a mixed pyright-and-mypy codebase.
A related debugging tip: reveal_type behaves differently at the definition site versus the call site. Inside apply’s body, reveal_type(args) prints the unresolved tuple[*Ts], because Ts is still a free variable there; only at a concrete call such as apply(build_label, 3, "widget") does the checker substitute the binding and report tuple[int, str]. When a variadic signature seems wrong, always inspect reveal_type at a real call with concrete arguments — the definition-site type is deliberately abstract and tells you nothing about resolution. And because --strict mypy tightens what it accepts around unconstrained variadics from release to release, assert your CI against a pinned mypy rather than “latest”.
Common mistakes
The failure modes below all produce named diagnostics, so you can map an error code straight back to its cause.
- Bare
Tswithout the star:def f(*args: Ts)instead of*args: *Tsis invalid — mypy reports[valid-type], pyrightreportInvalidTypeForm. The unpack star (or the equivalentUnpack[Ts]) is mandatory in both argument andtuplepositions; a bareTypeVarTupleis never a legal standalone annotation. - Two variadics in one signature:
def f(*a: *Ts, **b: *Us)cannot be resolved because the checker cannot tell where one sequence ends and the next begins; it emits[misc]/reportGeneralTypeIssues. Keep oneTypeVarTupleper signature and use fixed prefix/suffix elements for the rest. - Unpacking a variable-length result blindly:
r, c = self._dimsassumes exactly two elements; the static type may claim so while the real length differs, giving aValueErrorat runtime that no checker predicted. Guard withassert len(self._dims) == 2when you unpack a variadically-typed tuple into a fixed number of names. - Assuming old mypy handles suffixes:
tuple[*Ts, int]mis-resolved before mypy’s 1.x line matured — verify your installed version rather than rewriting a correct annotation. A signature that type-checks under pyright but fails under an old mypy is usually a mypy bug, not a mistake in your code. - Reaching for
*Tswhen a homogeneous tuple fits: if every argument shares one type,*args: Tyieldingtuple[T, ...]is simpler and better supported than a variadic; reserve*Tsfor genuinely heterogeneous positions.
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.