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,
- if they are defined literally
- and are instances of the type of the literal
(So this would be applicable mostly toIntEnum
andStrEnum
, but not regularEnum
)
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:
- It is type safe, if the
enum
subclasses the corresponding type. - 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:
Literal types indicate that a variable has a specific and concrete value.
Exactly what enums doGiven some value v that is a member of type T, the type Literal[v] shall be treated as a subtype of T
Exactly how enums work, for exampleisisntance(Number.ONE, int)
is True.
- 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.