Amend PEP 586 to make enum values subtypes of Literal (original) (raw)

Inspired by this highly upvoted issue: Literal of enum values · Issue #781 · python/typing · GitHub, I propose that enum values,

Then they should be subtypes of the corresponding literal type. For example, Number.ONE should be considered a subtype of Literal[1], but Weekday.MONDAY should not be considered a subtype of Literal[1], because Weekday doesn’t subclass int. Essentially, I propose the following amended inference rule for enums/literals:

enum.CASE <: Literal[<val>] if and only if type(enum.CASE) <: type(<val>) and enum.CASE.value = <val> literally. Moreover, in this case, equality comparisons should evaluate to True.

The rationale is that:

  1. It is type safe, if the enum subclasses the corresponding type.
  2. It is consistent with runtime behavior.
    In fact, both pyright and mypy are not consistent with runtime behavior here in some cases:
    Simple comparison [pyright playground], [mypy-playground]:
from enum import IntEnum  
class Number(IntEnum):  
    ONE = 1  
    TWO = 2  
if 1 == Number.ONE:  # <- incorrect no-overlap  
    print("equal")  # get's called at runtime.  

match-case example [pyright playground], [mypy-playground]:

from enum import StrEnum  
from typing import Literal, assert_never  
class Options(StrEnum):  
    A = "foo"  
    B = "bar"  
def show(x: Literal["foo", "bar"]) -> None:  
    match x:  
        case Options.A:  # <-- incorrectly marked as unreachable  
            return print("It's a foo!")  
        case Options.B:  # <-- incorrectly marked as unreachable  
            return print("It's a bar!")  
    assert_never(x)    # <-- false positive  
show("foo")  # prints "It's a foo!"  
show("bar")  # prints "It's a bar!"  

And if we annotate show(x: Options) instead, then we get flagged at the call site if we pass literal strings. So, if both behaviors are to be allowed one has to annotate with Options | Literal["foo", "bar"] which introduces lots of redundancy. To get the checkers happy, something like this is needed. 3. It is consistent with the “core behavior” of Literal types:

  1. It simplifies type hinting dramatically in some cases (as per Literal of enum values · Issue #781 · python/typing · GitHub)

A small update since this is still getting views/likes: pyright added support for this feature in 1.1.375. ty seems to support it out-of-the-box.

from enum import StrEnum
from typing import Literal

class Options(StrEnum):
    X = "X"
    Y = "Y"

x: Literal["X"] = Options.X
checker version status
pyright >=1.1.375 passes
mypy 1.16.0 fails
ty 0.0.1-alpha.8 passes
pyrefly 0.18.0 fails

InSync (InSync) June 6, 2025, 1:01pm 3

Correction: ty doesn’t support enums yet.

Jelle (Jelle Zijlstra) June 6, 2025, 2:01pm 4

I don’t think this should be allowed. We should instead say clearly that Literal[X] means that the object is exactly that value, i.e. Literal[1] is only the instance of int, not a subclass.

Allowing subclasses of ints to inhabit Literal[1] would break a lot of assumptions that type checkers use and that make Literals convenient to use. For example, type checkers may use x == 1 to narrow a type to Literal[1]; or if x to narrow Literal[0] out of a type; or they may support mathematical operations on literals. All of those become unsafe if the object may be a subclass of int.

Here’s a sample of unsound behaviors in pyright due to this feature:

from enum import IntEnum
from typing import Literal

class X(IntEnum):
    a = 1

    def __add__(self, value: int, /) -> int:
        return self.value + value + 42

    def __eq__(self, other: object):
        return False

    def __bool__(self) -> bool:
        return False

def f(x: Literal[1]):
    print(reveal_type(x + 1))  # 44 at runtime, 2 according to pyright

def g(x: Literal[1, 2]):
    if x == 1:  # false for X.a
        reveal_type(x)  # Literal[1]
    else:
        reveal_type(x)  # Literal[2]

def h(x: Literal[0, 1]):
    if x:  # false for X.a
        reveal_type(x)  # Literal[1]
    else:
        reveal_type(x)  # Literal[0]

f(X.a)
g(X.a)
h(X.a)

Jelle (Jelle Zijlstra) June 6, 2025, 2:18pm 5

And to provide a more constructive solution to the linked issue, I would suggest adding new type constructors EnumValues[Enum] and EnumNames[Enum]. These would accept a single argument, which must be an Enum class, and be equivalent to a union of the Literals of the names or values of the enum. For example, given the enum:

class E(enum.Enum):
    a = 1
    b = 2

EnumNames[E] would be equivalent to Literal["a", "b"], and EnumValues[E] would be Literal[1, 2]. It would be an error to use EnumValues on an enum with values that are not compatible with Literal.

erictraut (Eric Traut) June 6, 2025, 3:11pm 6

I agree with Jelle’s argument here. I’ve created a bug report in the pyright issue tracker and plan to revert that change.