From the typing spec:
Arbitrary-length homogeneous tuples can be expressed using one type and an ellipsis, for example
tuple[int, ...]
. This type is equivalent to a union of tuples containing zero or moreint
elements (tuple[()] | tuple[int] | tuple[int, int] | tuple[int, int, int] | ...
).
A tuple[int, *tuple[int, ...]]
accepts everything that tuple[int, ...]
accepts, but rejects tuple[()]
. So it’s equivalent to a union of tuples containing one or more int
elements, i.e. tuple[int] | tuple[int, int] | tuple[int, int, int] | ...
. We therefore have that tuple[()] | tuple[int, *tuple[int, ...]]
is equivalent to tuple[()] | tuple[int] | tuple[int, int] | tuple[int, int, int] | ...
, which by definition is equivalent to tuple[int, ...]
. There’s no Any
involved here, so we can use transitivity to conclude that tuple[()] | tuple[int, *tuple[int, ...]]
and tuple[int, ...]
are equivalent types.
Another way of looking at it, is that tuple[int, ...]
represents len(_) >=0
and tuple[()] | tuple[int, *tuple[int, ...]]
represents len(_) >=1
. We know that len(_)
is integral, so we can safely simplify the statement len(x) ==0 or len(x) >= 1
to len(x) >= 0
. Makes sense, right?
However, neither mypy
, pyright
, or pyrefly
allow assigning tuple[int, ...]
to tuple[()] | tuple[int, *tuple[int, ...]]
at the moment. The only exception is ty
. A demonstration of this can be seen in the following .pyi
example:
type Eq0 = tuple[()]
type Eq1 = tuple[int]
type Ge0 = tuple[int, ...]
type Ge1 = tuple[int, *Ge0]
eq0: Eq0
eq1: Eq1
ge0: Ge0
ge1: Ge1
eq0_ge1__eq0: Eq0 | Ge1 = eq0 # mypy: ✅, pyright: ✅, pyrefly: ✅, ty: ✅
eq0_ge1__eq1: Eq0 | Ge1 = eq1 # mypy: ✅, pyright: ✅, pyrefly: ✅, ty: ✅
eq0_ge1__ge0: Eq0 | Ge1 = ge0 # mypy: ❌, pyright: ❌, pyrefly: ❌, ty: ✅
eq0_ge1__ge1: Eq0 | Ge1 = ge1 # mypy: ✅, pyright: ✅, pyrefly: ✅, ty: ✅
I can imagine that this isn’t easy to implement in type-checkers. But even so, seemingly small logical inconsistencies like these tend to pile up in the form of frustration, especially for those new to typing, in my experience. And for what it’s worth; I personally care about this issue because this because this is making it more difficult to implement shape-typing in numpy, and working around it might be a bit tricky. But I’m sure that there are more use-cases that could benefit from having this solved.