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.

Prefix, variadic, and suffix in a tuple annotation The annotation tuple[int, star Ts, str] splits into a fixed leading int, a variadic middle standing for any number of types, and a fixed trailing str. tuple[int, *Ts, str] int fixed prefix *Ts 0..n middle elements str fixed suffix
A variadic tuple can pin exact types on either side of the *Ts that soaks up the middle.

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.

How *args binds Ts and threads through the callable The positional arguments bind Ts to the sequence int, str, the Callable parameter list reuses that same sequence, and the call returns R resolved to str. args 3, "widget" *Ts binds (int, str) Callable[[*Ts], R] [[int, str], R] R = str
The positional argument types bind *Ts, the callable reuses that same sequence, and R resolves to the return type.
# 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.

Prefix and suffix reconstructed around the variadic middle The call wrap_record with pk 7, body alice and True, and version v1 maps onto the return tuple int, str, bool, str where the ends are fixed and the middle is variadic. call pk=7 "alice", True version="v1" type int str, bool str prefix + suffix *Ts middle
Fixed ends line up positionally while *Ts reconstructs the variadic middle of the returned tuple.
# 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.

transpose as a type-level transition between array shapes Calling transpose on an Array typed Rows by Cols returns an Array typed Cols by Rows, and calling it again swaps the shape back. Array[Rows, Cols] Array[Cols, Rows] transpose() transpose()
The rank lives in the type, so transpose is a verifiable transition between two shape types.
# 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.

Runtime vs static analysis The *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: *Tstuple[*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 versus mypy support matrix for variadic generics Pyright handles forwarding, suffix, nested unpacks, and mixed TypeVars fully, while current mypy handles forwarding fully but treats suffix, nested, and mixed cases as recently fixed or partial. pyright mypy 1.10+ forwarding *args: *Ts full full fixed suffix tuple[*Ts, int] full recently fixed nested / bounded unpacks full may emit [misc] mixed *Ts + many TypeVars full partial
Pyright's solver resolves every variadic case; current mypy matches it on forwarding and has caught up elsewhere with occasional edge-case gaps.

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.

Decision tree for diagnosing variadic-tuple errors If the unpack star is missing the checker reports valid-type; if two variadics appear it reports misc; if a length is assumed at runtime add an assert, otherwise the annotation is sound. Star on Ts present? no [valid-type] / reportInvalidTypeForm yes Exactly one variadic? no [misc] / reportGeneralTypeIssues yes Length assumed at runtime? yes → add assert len(...) ; no → sound
Each common variadic-tuple mistake maps to a specific diagnostic and a concrete fix.
  • Bare Ts without the star: def f(*args: Ts) instead of *args: *Ts is invalid — mypy reports [valid-type], pyright reportInvalidTypeForm. The unpack star (or the equivalent Unpack[Ts]) is mandatory in both argument and tuple positions; a bare TypeVarTuple is 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 one TypeVarTuple per signature and use fixed prefix/suffix elements for the rest.
  • Unpacking a variable-length result blindly: r, c = self._dims assumes exactly two elements; the static type may claim so while the real length differs, giving a ValueError at runtime that no checker predicted. Guard with assert len(self._dims) == 2 when 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 *Ts when a homogeneous tuple fits: if every argument shares one type, *args: T yielding tuple[T, ...] is simpler and better supported than a variadic; reserve *Ts for 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.

Back to Variadic Generics with TypeVarTuple