Skip to content

Iterate a TypeVar through its upper bound when unpacking#3864

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

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. ✅

@meta-codesync

meta-codesync Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

@yangdanny97 has imported this pull request. If you are a Meta employee, you can view this in D109266531.

@stroxler stroxler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review automatically exported from Phabricator review in Meta.

@meta-codesync meta-codesync Bot closed this in b08246c Jun 23, 2026
@meta-codesync meta-codesync Bot added the Merged label Jun 23, 2026
@meta-codesync

meta-codesync Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@yangdanny97 merged this pull request in b08246c.

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]

4 participants