Summary
Narrowing a union that contains an enum-member Literal via is / is not / == / != no longer removes that member in the corresponding branch. The matching Literal survives in the union, producing false positives. This is a regression: ty 0.0.37 narrows correctly; 0.0.39 and 0.0.40 (latest) do not. isinstance() narrowing is unaffected.
Reproduction
from enum import Enum
from typing import Literal
class CM(Enum):
EMPTY = "empty"
QUIT = "quit" # a 2nd member is required to trigger the bug
def f(x: list[int] | Literal[CM.EMPTY]) -> None:
if x is CM.EMPTY:
return
reveal_type(x) # expected: list[int]
# actual (0.0.39 / 0.0.40): list[int] | Literal[CM.EMPTY]
def g(x: list[int] | Literal[CM.EMPTY]) -> None:
if isinstance(x, CM): # isinstance narrows correctly
return
reveal_type(x) # list[int] -- correct on all versions
Using the value in a return/assignment surfaces the false positive directly:
def h(x: list[int] | Literal[CM.EMPTY]) -> list[int]:
if x is CM.EMPTY:
return []
return x # error[invalid-return-type]: found `list[int] | Literal[CM.EMPTY]`
Expected
In the else branch, x narrows to list[int] (the Literal[CM.EMPTY] member is removed), matching ty 0.0.37, pyright, and mypy.
Actual
x keeps the Literal[CM.EMPTY] member, so reveal_type shows list[int] | Literal[CM.EMPTY] and downstream usages are flagged.
Trigger condition
The enum must have >= 2 members, and the union must mention only one member's Literal. With a single-member enum the complement ~Literal[CM.EMPTY] is empty, so the bug does not appear.
Versions
| ty version |
result |
| 0.0.37 |
narrows to list[int] (correct) |
| 0.0.39 |
keeps Literal[CM.EMPTY] (incorrect) |
| 0.0.40 (latest) |
keeps Literal[CM.EMPTY] (incorrect) |
is, is not, ==, != are all affected; isinstance works.
Possibly related
#3552 ("Incorrect is narrowing of code involving newtypes") — opposite symptom (over-eager intersection in the positive branch) but may share the same is-narrowing machinery.
Suspected cause
Possibly the "first-class support for enum complements" change (#24961, released in 0.0.38): the ~Literal[...] representation may not eliminate the matching Literal union member on the is / == branches. This is a hypothesis — the regression window is confirmed as 0.0.37 -> 0.0.39, but 0.0.38 was not individually bisected.
Summary
Narrowing a union that contains an enum-member
Literalviais/is not/==/!=no longer removes that member in the corresponding branch. The matchingLiteralsurvives in the union, producing false positives. This is a regression:ty0.0.37 narrows correctly; 0.0.39 and 0.0.40 (latest) do not.isinstance()narrowing is unaffected.Reproduction
Using the value in a return/assignment surfaces the false positive directly:
Expected
In the
elsebranch,xnarrows tolist[int](theLiteral[CM.EMPTY]member is removed), matchingty0.0.37, pyright, and mypy.Actual
xkeeps theLiteral[CM.EMPTY]member, soreveal_typeshowslist[int] | Literal[CM.EMPTY]and downstream usages are flagged.Trigger condition
The enum must have >= 2 members, and the union must mention only one member's
Literal. With a single-member enum the complement~Literal[CM.EMPTY]is empty, so the bug does not appear.Versions
list[int](correct)Literal[CM.EMPTY](incorrect)Literal[CM.EMPTY](incorrect)is,is not,==,!=are all affected;isinstanceworks.Possibly related
#3552 ("Incorrect
isnarrowing of code involving newtypes") — opposite symptom (over-eager intersection in the positive branch) but may share the sameis-narrowing machinery.Suspected cause
Possibly the "first-class support for enum complements" change (#24961, released in 0.0.38): the
~Literal[...]representation may not eliminate the matchingLiteralunion member on theis/==branches. This is a hypothesis — the regression window is confirmed as 0.0.37 -> 0.0.39, but 0.0.38 was not individually bisected.