Unbounded tuple unions

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 more int 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.

9 Likes

For what it’s worth, ty is not exactly doing the correct thing either: it doesn’t support PEP 646 yet, so Ge1 is resolved as Todo, a gradual form comparable to Any.

4 Likes

I just merged support for this pattern in my pycroscope type checker: Improve equivalence checking by JelleZijlstra · Pull Request #28 · JelleZijlstra/pycroscope · GitHub. However, there are still more complex equivalences among tuple types that this does not support.

1 Like