ruff/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md
David Peter 73107a083c
[ty] Type inference for comprehensions (#20962)
## Summary

Adds type inference for list/dict/set comprehensions, including
bidirectional inference:

```py
reveal_type({k: v for k, v in [("a", 1), ("b", 2)]})  # dict[Unknown | str, Unknown | int]

squares: list[int | None] = [x for x in range(10)]
reveal_type(squares)  # list[int | None]
```

## Ecosystem impact

I did spot check the changes and most of them seem like known
limitations or true positives. Without proper bidirectional inference,
we saw a lot of false positives.

## Test Plan

New Markdown tests
2025-11-02 14:35:33 +01:00

5 KiB

Comprehensions

Basic comprehensions

# revealed: int
[reveal_type(x) for x in range(3)]

class Row:
    def __next__(self) -> range:
        return range(3)

class Table:
    def __iter__(self) -> Row:
        return Row()

# revealed: tuple[int, range]
[reveal_type((cell, row)) for row in Table() for cell in row]

# revealed: int
{reveal_type(x): 0 for x in range(3)}

# revealed: int
{0: reveal_type(x) for x in range(3)}

Nested comprehension

# revealed: tuple[int, int]
[[reveal_type((x, y)) for x in range(3)] for y in range(3)]

Comprehension referencing outer comprehension

class Row:
    def __next__(self) -> range:
        return range(3)

class Table:
    def __iter__(self) -> Row:
        return Row()

# revealed: tuple[int, range]
[[reveal_type((cell, row)) for cell in row] for row in Table()]

Comprehension with unbound iterable

Iterating over an unbound iterable yields Unknown:

# error: [unresolved-reference] "Name `x` used when not defined"
# revealed: Unknown
[reveal_type(z) for z in x]

# error: [not-iterable] "Object of type `int` is not iterable"
# revealed: tuple[int, Unknown]
[reveal_type((x, z)) for x in range(3) for z in x]

Starred expressions

Starred expressions must be iterable

class NotIterable: ...

# This is fine:
x = [*range(3)]

# error: [not-iterable] "Object of type `NotIterable` is not iterable"
y = [*NotIterable()]

Async comprehensions

Basic

class AsyncIterator:
    async def __anext__(self) -> int:
        return 42

class AsyncIterable:
    def __aiter__(self) -> AsyncIterator:
        return AsyncIterator()

async def _():
    # revealed: int
    [reveal_type(x) async for x in AsyncIterable()]

Invalid async comprehension

This tests that we understand that async comprehensions do not work according to the synchronous iteration protocol

async def _():
    # error: [not-iterable] "Object of type `range` is not async-iterable"
    # revealed: Unknown
    [reveal_type(x) async for x in range(3)]

Comprehension expression types

The type of the comprehension expression itself should reflect the inferred element type:

from typing import TypedDict, Literal

# revealed: list[int | Unknown]
reveal_type([x for x in range(10)])

# revealed: set[int | Unknown]
reveal_type({x for x in range(10)})

# revealed: dict[int | Unknown, str | Unknown]
reveal_type({x: str(x) for x in range(10)})

# revealed: list[tuple[int, Unknown | str] | Unknown]
reveal_type([(x, y) for x in range(5) for y in ["a", "b", "c"]])

squares: list[int | None] = [x**2 for x in range(10)]
reveal_type(squares)  # revealed: list[int | None]

Inference for comprehensions takes the type context into account:

# Without type context:
reveal_type([x for x in [1, 2, 3]])  # revealed: list[Unknown | int]
reveal_type({x: "a" for x in [1, 2, 3]})  # revealed: dict[Unknown | int, str | Unknown]
reveal_type({str(x): x for x in [1, 2, 3]})  # revealed: dict[str | Unknown, Unknown | int]
reveal_type({x for x in [1, 2, 3]})  # revealed: set[Unknown | int]

# With type context:
xs: list[int] = [x for x in [1, 2, 3]]
reveal_type(xs)  # revealed: list[int]

ys: dict[int, str] = {x: str(x) for x in [1, 2, 3]}
reveal_type(ys)  # revealed: dict[int, str]

zs: set[int] = {x for x in [1, 2, 3]}

This also works for nested comprehensions:

table = [[(x, y) for x in range(3)] for y in range(3)]
reveal_type(table)  # revealed: list[list[tuple[int, int] | Unknown] | Unknown]

table_with_content: list[list[tuple[int, int, str | None]]] = [[(x, y, None) for x in range(3)] for y in range(3)]
reveal_type(table_with_content)  # revealed: list[list[tuple[int, int, str | None]]]

The type context is propagated down into the comprehension:

class Person(TypedDict):
    name: str

persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]]
reveal_type(persons)  # revealed: list[Person]

# TODO: This should be an error
invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]]

We promote literals to avoid overly-precise types in invariant positions:

reveal_type([x for x in ("a", "b", "c")])  # revealed: list[str | Unknown]
reveal_type({x for x in (1, 2, 3)})  # revealed: set[int | Unknown]
reveal_type({k: 0 for k in ("a", "b", "c")})  # revealed: dict[str | Unknown, int | Unknown]

Type context can prevent this promotion from happening:

list_of_literals: list[Literal["a", "b", "c"]] = [x for x in ("a", "b", "c")]
reveal_type(list_of_literals)  # revealed: list[Literal["a", "b", "c"]]

dict_with_literal_keys: dict[Literal["a", "b", "c"], int] = {k: 0 for k in ("a", "b", "c")}
reveal_type(dict_with_literal_keys)  # revealed: dict[Literal["a", "b", "c"], int]

dict_with_literal_values: dict[str, Literal[1, 2, 3]] = {str(k): k for k in (1, 2, 3)}
reveal_type(dict_with_literal_values)  # revealed: dict[str, Literal[1, 2, 3]]

set_with_literals: set[Literal[1, 2, 3]] = {k for k in (1, 2, 3)}
reveal_type(set_with_literals)  # revealed: set[Literal[1, 2, 3]]