ruff/crates/ty_python_semantic/resources/mdtest/literal_promotion.md
Ibraheem Ahmed c5d654bce8
[ty] Improve literal promotion heuristics (#21439)
## Summary

Extends literal promotion to apply to any generic method, as opposed to
only generic class constructors. This PR also improves our literal
promotion heuristics to only promote literals in non-covariant position
in the return type, and avoid promotion if the literal is present in
non-covariant position in any argument type.

Resolves https://github.com/astral-sh/ty/issues/1357.
2025-11-14 16:13:56 -05:00

10 KiB

Literal promotion

[environment]
python-version = "3.12"

There are certain places where we promote literals to their common supertype.

All literal types are promotable

from enum import Enum
from typing import Literal, LiteralString

class MyEnum(Enum):
    A = 1

def promote[T](x: T) -> list[T]:
    return [x]

def _(
    lit1: Literal["x"],
    lit2: LiteralString,
    lit3: Literal[True],
    lit4: Literal[b"x"],
    lit5: Literal[MyEnum.A],
):
    reveal_type(promote(lit1))  # revealed: list[str]
    reveal_type(promote(lit2))  # revealed: list[str]
    reveal_type(promote(lit3))  # revealed: list[bool]
    reveal_type(promote(lit4))  # revealed: list[bytes]
    reveal_type(promote(lit5))  # revealed: list[MyEnum]

Function types are also promoted to their Callable form:

def lit6(_: int) -> int:
    return 0

reveal_type(promote(lit6))  # revealed: list[(_: int) -> int]

Invariant collection literals are promoted

The elements of invariant collection literals, i.e. lists, dictionaries, and sets, are promoted:

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

Covariant collection literals are not promoted:

reveal_type((1, 2, 3))  # revealed: tuple[Literal[1], Literal[2], Literal[3]]
reveal_type(frozenset((1, 2, 3)))  # revealed: frozenset[Literal[1, 2, 3]]

Invariant and contravariant return types are promoted

Literals are promoted if they are in non-covariant position in the return type of a generic function, or constructor of a generic class:

class Bivariant[T]:
    def __init__(self, value: T): ...

class Covariant[T]:
    def __init__(self, value: T): ...
    def pop(self) -> T:
        raise NotImplementedError

class Contravariant[T]:
    def __init__(self, value: T): ...
    def push(self, value: T) -> None:
        pass

class Invariant[T]:
    x: T

    def __init__(self, value: T): ...

def f1[T](x: T) -> Bivariant[T] | None: ...
def f2[T](x: T) -> Covariant[T] | None: ...
def f3[T](x: T) -> Covariant[T] | Bivariant[T] | None: ...
def f4[T](x: T) -> Contravariant[T] | None: ...
def f5[T](x: T) -> Invariant[T] | None: ...
def f6[T](x: T) -> Invariant[T] | Contravariant[T] | None: ...
def f7[T](x: T) -> Covariant[T] | Contravariant[T] | None: ...
def f8[T](x: T) -> Invariant[T] | Covariant[T] | None: ...
def f9[T](x: T) -> tuple[Invariant[T], Invariant[T]] | None: ...
def f10[T, U](x: T, y: U) -> tuple[Invariant[T], Covariant[U]] | None: ...
def f11[T, U](x: T, y: U) -> tuple[Invariant[Covariant[T] | None], Covariant[U]] | None: ...

reveal_type(Bivariant(1))  # revealed: Bivariant[Literal[1]]
reveal_type(Covariant(1))  # revealed: Covariant[Literal[1]]

reveal_type(Contravariant(1))  # revealed: Contravariant[int]
reveal_type(Invariant(1))  # revealed: Invariant[int]

reveal_type(f1(1))  # revealed: Bivariant[Literal[1]] | None
reveal_type(f2(1))  # revealed: Covariant[Literal[1]] | None
reveal_type(f3(1))  # revealed: Covariant[Literal[1]] | Bivariant[Literal[1]] | None

reveal_type(f4(1))  # revealed: Contravariant[int] | None
reveal_type(f5(1))  # revealed: Invariant[int] | None
reveal_type(f6(1))  # revealed: Invariant[int] | Contravariant[int] | None
reveal_type(f7(1))  # revealed: Covariant[int] | Contravariant[int] | None
reveal_type(f8(1))  # revealed: Invariant[int] | Covariant[int] | None
reveal_type(f9(1))  # revealed: tuple[Invariant[int], Invariant[int]] | None

reveal_type(f10(1, 1))  # revealed: tuple[Invariant[int], Covariant[Literal[1]]] | None
reveal_type(f11(1, 1))  # revealed: tuple[Invariant[Covariant[int] | None], Covariant[Literal[1]]] | None

Invariant and contravariant literal arguments are respected

If a literal type is present in non-covariant position in the return type, but also in non-covariant position in an argument type, we respect the explicitly annotated argument, and avoid promotion:

from typing import Literal

class Covariant[T]:
    def pop(self) -> T:
        raise NotImplementedError

class Contravariant[T]:
    def push(self, value: T) -> None:
        pass

class Invariant[T]:
    x: T

def f1[T](x: T) -> Invariant[T] | None: ...
def f2[T](x: Covariant[T]) -> Invariant[T] | None: ...
def f3[T](x: Invariant[T]) -> Invariant[T] | None: ...
def f4[T](x: Contravariant[T]) -> Invariant[T] | None: ...
def f5[T](x: Covariant[Invariant[T]]) -> Invariant[T] | None: ...
def f6[T](x: Covariant[Invariant[T]]) -> Invariant[T] | None: ...
def f7[T](x: Covariant[T], y: Invariant[T]) -> Invariant[T] | None: ...
def f8[T](x: Invariant[T], y: Covariant[T]) -> Invariant[T] | None: ...
def f9[T](x: Covariant[T], y: Contravariant[T]) -> Invariant[T] | None: ...
def f10[T](x: Contravariant[T], y: Covariant[T]) -> Invariant[T] | None: ...
def _(
    lit: Literal[1],
    cov: Covariant[Literal[1]],
    inv: Invariant[Literal[1]],
    cont: Contravariant[Literal[1]],
    inv2: Covariant[Invariant[Literal[1]]],
):
    reveal_type(f1(lit))  # revealed: Invariant[int] | None
    reveal_type(f2(cov))  # revealed: Invariant[int] | None

    reveal_type(f3(inv))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f4(cont))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f5(inv2))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f6(inv2))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f7(cov, inv))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f8(inv, cov))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f9(cov, cont))  # revealed: Invariant[Literal[1]] | None
    reveal_type(f10(cont, cov))  # revealed: Invariant[Literal[1]] | None

Note that we consider variance of argument types, not parameters. If the literal is in covariant position in the declared parameter type, but invariant in the argument type, we still avoid promotion:

from typing import Iterable

class X[T]:
    def __init__(self, x: Iterable[T]): ...

def _(x: list[Literal[1]]):
    reveal_type(X(x))  # revealed: X[Literal[1]]

Literals are promoted recursively

from typing import Literal

def promote[T](x: T) -> list[T]:
    return [x]

def _(x: tuple[tuple[tuple[Literal[1]]]]):
    reveal_type(promote(x))  # revealed: list[tuple[tuple[tuple[int]]]]

x1 = ([1, 2], [(3,), (4,)], ["5", "6"])
reveal_type(x1)  # revealed: tuple[list[Unknown | int], list[Unknown | tuple[int]], list[Unknown | str]]

However, this promotion should not take place if the literal type appears in contravariant position:

from typing import Callable, Literal

def in_negated_position(non_zero_number: int):
    if non_zero_number == 0:
        raise ValueError()

    reveal_type(non_zero_number)  # revealed: int & ~Literal[0]

    reveal_type([non_zero_number])  # revealed: list[Unknown | (int & ~Literal[0])]

def in_parameter_position(callback: Callable[[Literal[1]], None]):
    reveal_type(callback)  # revealed: (Literal[1], /) -> None

    reveal_type([callback])  # revealed: list[Unknown | ((Literal[1], /) -> None)]

def double_negation(callback: Callable[[Callable[[Literal[1]], None]], None]):
    reveal_type(callback)  # revealed: ((Literal[1], /) -> None, /) -> None

    reveal_type([callback])  # revealed: list[Unknown | (((int, /) -> None, /) -> None)]

Literal promotion should also not apply recursively to type arguments in contravariant/invariant position:

class Bivariant[T]:
    pass

class Covariant[T]:
    def pop(self) -> T:
        raise NotImplementedError

class Contravariant[T]:
    def push(self, value: T) -> None:
        pass

class Invariant[T]:
    x: T

def _(
    bivariant: Bivariant[Literal[1]],
    covariant: Covariant[Literal[1]],
    contravariant: Contravariant[Literal[1]],
    invariant: Invariant[Literal[1]],
):
    reveal_type([bivariant])  # revealed: list[Unknown | Bivariant[int]]
    reveal_type([covariant])  # revealed: list[Unknown | Covariant[int]]

    reveal_type([contravariant])  # revealed: list[Unknown | Contravariant[Literal[1]]]
    reveal_type([invariant])  # revealed: list[Unknown | Invariant[Literal[1]]]

Literal annnotations are respected

Explicitly annotated Literal types will prevent literal promotion:

from enum import Enum
from typing_extensions import Literal, LiteralString

class Color(Enum):
    RED = "red"

type Y[T] = list[T]

class X[T]:
    value: T

    def __init__(self, value: T): ...

def x[T](x: T) -> X[T]:
    return X(x)

x1: list[Literal[1]] = [1]
reveal_type(x1)  # revealed: list[Literal[1]]

x2: list[Literal[True]] = [True]
reveal_type(x2)  # revealed: list[Literal[True]]

x3: list[Literal["a"]] = ["a"]
reveal_type(x3)  # revealed: list[Literal["a"]]

x4: list[LiteralString] = ["a", "b", "c"]
reveal_type(x4)  # revealed: list[LiteralString]

x5: list[list[Literal[1]]] = [[1]]
reveal_type(x5)  # revealed: list[list[Literal[1]]]

x6: dict[list[Literal[1]], list[Literal[Color.RED]]] = {[1]: [Color.RED, Color.RED]}
reveal_type(x6)  # revealed: dict[list[Literal[1]], list[Color]]

x7: X[Literal[1]] = X(1)
reveal_type(x7)  # revealed: X[Literal[1]]

x8: X[int] = X(1)
reveal_type(x8)  # revealed: X[int]

x9: dict[list[X[Literal[1]]], set[Literal[b"a"]]] = {[X(1)]: {b"a"}}
reveal_type(x9)  # revealed: dict[list[X[Literal[1]]], set[Literal[b"a"]]]

x10: list[Literal[1, 2, 3]] = [1, 2, 3]
reveal_type(x10)  # revealed: list[Literal[1, 2, 3]]

x11: list[Literal[1] | Literal[2] | Literal[3]] = [1, 2, 3]
reveal_type(x11)  # revealed: list[Literal[1, 2, 3]]

x12: Y[Y[Literal[1]]] = [[1]]
reveal_type(x12)  # revealed: list[Y[Literal[1]]]

x13: list[tuple[Literal[1], Literal[2], Literal[3]]] = [(1, 2, 3)]
reveal_type(x13)  # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]

x14: list[tuple[int, str, int]] = [(1, "2", 3), (4, "5", 6)]
reveal_type(x14)  # revealed: list[tuple[int, str, int]]

x15: list[tuple[Literal[1], ...]] = [(1, 1, 1)]
reveal_type(x15)  # revealed: list[tuple[Literal[1], ...]]

x16: list[tuple[int, ...]] = [(1, 1, 1)]
reveal_type(x16)  # revealed: list[tuple[int, ...]]

x17: list[int | Literal[1]] = [1]
reveal_type(x17)  # revealed: list[int]

x18: list[Literal[1, 2, 3, 4]] = [1, 2]
reveal_type(x18)  # revealed: list[Literal[1, 2, 3, 4]]

x19: list[Literal[1]]

x19 = [1]
reveal_type(x19)  # revealed: list[Literal[1]]

(x19 := [1])
reveal_type(x19)  # revealed: list[Literal[1]]

x20: list[Literal[1]] | None = [1]
reveal_type(x20)  # revealed: list[Literal[1]]

x21: X[Literal[1]] | None = x(1)
reveal_type(x21)  # revealed: X[Literal[1]]