Skip to content

Fix narrowing of union types containing StrEnum/IntEnum and Literal#20930

Open
bxff wants to merge 3 commits intopython:masterfrom
bxff:fix/strenum-literal-union-narrowing
Open

Fix narrowing of union types containing StrEnum/IntEnum and Literal#20930
bxff wants to merge 3 commits intopython:masterfrom
bxff:fix/strenum-literal-union-narrowing

Conversation

@bxff
Copy link

@bxff bxff commented Feb 28, 2026

When a union type contains both StrEnum/IntEnum and Literal/None types (e.g. Color | Literal["none"]), the ambiguity guard in narrow_type_by_identity_equality skips all narrowing — even for union members with well-defined equality.

This processes Literal/None union items individually via conditional_types while keeping StrEnum items as-is. The result is built with UnionType.make_union to avoid make_simplified_union absorbing the StrEnum back into str.

def test(value: Color | Literal["none"]):
    if value == "none":
        reveal_type(value)  # Color | Literal['none']
    else:
        reveal_type(value)  # Color  (was Color | Literal['none'])

Fixes #20915

@github-actions

This comment has been minimized.

@A5rocks
Copy link
Collaborator

A5rocks commented Mar 1, 2026

I'm fairly certain this can be done with less code. Look at how non-StrEnum enums support this, I guess?

@ilevkivskyi
Copy link
Member

Also I think we can potentially narrow in the if branch if we know the StrEnum values, don't remember if we have the necessary infra for this. In any case, please do note that thoughtless use of LLMs is not really helpful.

Copy link
Member

@ilevkivskyi ilevkivskyi left a comment

Choose a reason for hiding this comment

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

See above

When a union contains both StrEnum/IntEnum and Literal/None types,
the ambiguity guard in narrow_type_by_identity_equality skips all
narrowing. This processes Literal/None union items individually via
conditional_types while keeping enum items as-is.

Fixes python#20915
@bxff bxff force-pushed the fix/strenum-literal-union-narrowing branch from f41a44b to 67c6753 Compare March 1, 2026 17:43
@bxff
Copy link
Author

bxff commented Mar 1, 2026

Pushed a slimmer revision.

@A5rocks Regular enums never hit the ambiguity guard at all (ambiguous_enum_equality_keys returns empty for them), so there's no simpler path to copy from. I tried skipping the fallback recursion for non-enum LiteralType in ambiguous_enum_equality_keys, but that breaks SE == 'a'restrict_subtype_away erases Literal[SE.B] to SE, and is_proper_subtype(SE, str) kills all members. The fix instead iterates the original union items when the guard fires: Literal/None items get narrowed via conditional_types, everything else stays as-is, and the result is built with UnionType.make_union to avoid make_simplified_union absorbing the StrEnum back into str.

@ilevkivskyi I did look into if-branch narrowing. Same erase_type problem, covers_at_runtime erases Literal[SE.B]("b") to SE, so any string literal target covers it and non-matching members get dropped. Would need restrict_subtype_away to preserve literal values, which felt out of scope for this PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 1, 2026

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

@A5rocks
Copy link
Collaborator

A5rocks commented Mar 1, 2026

Ah I see, I hadn't completely internalized that this is ambiguous. I'll think a bit about whether there's some easy way, but probably this is fine.

Edit: Maybe remove this ambiguity check and make is_target_for_value_narrowing return False? We will only narrow in the else branch then. I assume that's what @ilevkivskyi said and you tried, though.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mypy is unable to narrow a union type that includes a StrEnum subclass and a Literal

3 participants