Skip to content

Iterate a TypeVar through its upper bound when unpacking#3864

Open
mikeleppane wants to merge 1 commit into
facebook:mainfrom
mikeleppane:fix/typevar-tuple-unpack-3841
Open

Iterate a TypeVar through its upper bound when unpacking#3864
mikeleppane wants to merge 1 commit into
facebook:mainfrom
mikeleppane:fix/typevar-tuple-unpack-3841

Conversation

@mikeleppane

Copy link
Copy Markdown
Contributor

Fixes #3841

What

Unpacking a value whose type is a TypeVar bounded by a tuple now preserves the
positional element types, matching the behavior of the equivalent
direct-tuple parameter.

def incorrect_deduction[Z: tuple[str, int]](x: Z):
    u, v = x
    reveal_type(u)  # before: int | str   after: str
    reveal_type(v)  # before: int | str   after: int

def correct_deduction(x: tuple[str, int]):
    u, v = x
    reveal_type(u)  # str  (always correct)
    reveal_type(v)  # int

pyright (str*/int*), mypy (str/int), and ty (str/int) all produce the
positional types; pyrefly was the outlier.

Why

A bounded TypeVar Z <: tuple[str, int] is, within the body, a fixed-shape
2-tuple: position 0 is a str, position 1 an int. Unpacking should hand back
those labelled positions.

AnswersSolver::iterate had explicit arms for concrete tuples, Type::Var, and
Type::Union, but no arm for Type::Quantified. A bounded TypeVar therefore
fell through to the generic iterable-protocol fallback, which views the value as
Iterable[T] for a single T and collapses the tuple to the join of its
element types (str | int). Because that fallback yields one element type for
every index, both u and v came out int | str.

How

pyrefly/lib/alt/solve.rs — add one arm to iterate:

Type::Quantified(q) if q.is_type_var() => {
    self.iterate(&q.upper_bound(self.stdlib, self.heap), range, errors, orig_context)
}

A TypeVar iterates like its upper bound. The same resolve-through-the-bound rule
already used for attribute access on a bounded TypeVar (attr.rs). Reusing
Quantified::upper_bound means the existing arms handle every restriction shape
with no extra branching:

  • Bound (tuple[str, int]) → recurses into the concrete-tuple arm → positions preserved.
  • Bound (list[int]) → recurses into the fallback → int — identical to today.
  • Constraints → union → the Type::Union arm → per-position union.
  • Unrestrictedobject → not iterable → correct error.

Guarded on q.is_type_var(), so ParamSpec and TypeVarTuple are untouched (a
*Ts inside a tuple is still handled by the existing Tuple::Unpacked arm).

Deliberately not touched: binding_to_type_unpacked_value and the iterable
protocol were already correct; the only gap was the missing arm. The
not-iterable error for a non-iterable bound now names the bound (e.g. object,
int) rather than the TypeVar. This matches what pyright and mypy already
report.

Test plan

New testcase!s in pyrefly/lib/test/tuple.rs:

Test Protects
test_unpack_typevar_bound_to_tuple the repro: Z: tuple[str, int]str, int
test_unpack_typevar_bound_to_tuple_three_elements 3-element bound stays positional
test_unpack_typevar_bound_to_unbounded_tuple tuple[int, ...] bound → int per target
test_unpack_typevar_bound_to_tuple_starred starred a, *b through the bound
test_unpack_constrained_typevar_tuple constrained TypeVar → per-position union
test_unpack_typevar_unbounded_not_iterable unbounded Z still errors (no over-broadening)
test_unpack_typevar_bound_not_iterable non-iterable bound (Z: int) still errors
  • cargo test -p pyrefly --lib: 5723 passed, 0 failed
  • Formatting + lint (test.py --no-test --no-conformance --no-jsonschema): clean
  • Conformance: clean, no generated changes
  • mypy_primer (~52 projects incl. pandas, pandas-stubs, pydantic, sympy, scipy-stubs, xarray, attrs, pandera): 0 diagnostic diffs — no regressions

Unpacking `u, v = x` where `x: Z` and `Z` is a TypeVar bounded by
`tuple[str, int]` revealed `int | str` for both targets instead of the
positional `str` and `int`. The direct `x: tuple[str, int]` case was
already correct, so the two were not equivalent, even though pyright,
mypy, and ty all agree on the positional types.

`AnswersSolver::iterate` had no arm for `Type::Quantified`, so a bounded
TypeVar fell through to the iterable-protocol fallback, which views the
value as `Iterable[T]` and collapses a fixed-shape tuple to the join of
its element types, losing each position. Iterate a TypeVar through its
upper bound instead, so the bound's tuple shape is preserved. Reusing
`upper_bound` lets the existing union and tuple arms handle the rest:
constrained TypeVars iterate per constraint, and an unbounded TypeVar
resolves to `object` and is correctly reported as not iterable. This is
the same resolve-through-the-bound rule already used for attribute
access on a bounded TypeVar.

Fixes facebook#3841
@github-actions

Copy link
Copy Markdown

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subtype of tuple[A,B] deduced as having elements of type [A|B, A|B]

2 participants