Skip to content

[ty] Optimize union building for unions with many enum-literal members#22363

Merged
AlexWaygood merged 6 commits intomainfrom
alex/enum-literal-unions
Jan 8, 2026
Merged

[ty] Optimize union building for unions with many enum-literal members#22363
AlexWaygood merged 6 commits intomainfrom
alex/enum-literal-unions

Conversation

@AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Jan 3, 2026

Summary

When building unions, we have fast paths for int-literals, bytes-literals and string-literals so that we avoid repeated expensive subtype-of checks. However, we currently have no equivalent fast path for enum literals. This PR adds that fast path.

This PR leads to a dramatic performance improvement for the snippet in astral-sh/ty#1588 (comment). (But I would still say our performance is too slow even with this PR, so I wouldn't say it closes that issue.)

Test Plan

  • Existing mdtests all pass
  • I added a new mdtest for an enum with many members, showing that our reachability analysis still works as expected
  • QUICKCHECK_TESTS=1000000 cargo test --release -p ty_python_semantic -- --ignored types::property_tests::stable still passes

@AlexWaygood AlexWaygood added performance Potential performance improvement ty Multi-file analysis & type inference labels Jan 3, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 3, 2026

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 3, 2026

mypy_primer results

Changes were detected when running on open source projects
tornado (https://github.com/tornadoweb/tornado)
- tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _VT@next | _T@next`
+ tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _T@next | _VT@next`

pydantic (https://github.com/pydantic/pydantic)
- pydantic/fields.py:943:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:943:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:983:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:983:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1026:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:1026:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1066:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:1066:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1109:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:1109:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1148:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:1148:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1188:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
+ pydantic/fields.py:1188:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
- pydantic/fields.py:1567:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`, found `Top[dict[Unknown, Unknown]] | (((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) & ~Top[dict[Unknown, Unknown]]) | None`
+ pydantic/fields.py:1567:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`, found `Top[dict[Unknown, Unknown]] | (((dict[str, Divergent], /) -> None) & ~Top[dict[Unknown, Unknown]]) | None`

prefect (https://github.com/PrefectHQ/prefect)
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:461:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:461:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:535:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:535:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:610:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:610:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:685:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:685:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:760:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:760:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:835:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, None | Unknown]` is not awaitable
+ src/integrations/prefect-dbt/prefect_dbt/cli/commands.py:835:21: error[invalid-await] `Unknown | None | Coroutine[Any, Any, Unknown | None]` is not awaitable
- src/prefect/deployments/runner.py:795:70: warning[possibly-missing-attribute] Attribute `__name__` may be missing on object of type `Unknown | (((...) -> Any) & ((*args: object, **kwargs: object) -> object))`
+ src/prefect/deployments/runner.py:795:70: warning[possibly-missing-attribute] Attribute `__name__` may be missing on object of type `Unknown | ((...) -> Any)`
+ src/prefect/flow_engine.py:812:32: error[invalid-await] `Unknown | R@FlowRunEngine | Coroutine[Any, Any, R@FlowRunEngine]` is not awaitable
+ src/prefect/flow_engine.py:1401:24: error[invalid-await] `Unknown | R@AsyncFlowRunEngine | Coroutine[Any, Any, R@AsyncFlowRunEngine]` is not awaitable
+ src/prefect/flow_engine.py:1482:43: error[invalid-argument-type] Argument to function `next` is incorrect: Expected `SupportsNext[Unknown]`, found `Unknown | R@run_generator_flow_sync`
+ src/prefect/flow_engine.py:1490:21: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_flow_sync`
+ src/prefect/flow_engine.py:1524:44: warning[possibly-missing-attribute] Attribute `__anext__` may be missing on object of type `Unknown | R@run_generator_flow_async`
+ src/prefect/flow_engine.py:1531:25: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_flow_async`
- src/prefect/flows.py:286:34: error[unresolved-attribute] Object of type `((**P@Flow) -> R@Flow) & ((*args: object, **kwargs: object) -> object)` has no attribute `__name__`
+ src/prefect/flows.py:286:34: error[unresolved-attribute] Object of type `(**P@Flow) -> R@Flow` has no attribute `__name__`
- src/prefect/flows.py:404:68: error[unresolved-attribute] Object of type `((**P@Flow) -> R@Flow) & ((*args: object, **kwargs: object) -> object)` has no attribute `__name__`
+ src/prefect/flows.py:404:68: error[unresolved-attribute] Object of type `(**P@Flow) -> R@Flow` has no attribute `__name__`
- src/prefect/flows.py:1750:53: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 5361 diagnostics
+ Found 5366 diagnostics

scikit-build-core (https://github.com/scikit-build/scikit-build-core)
+ src/scikit_build_core/build/wheel.py:98:20: error[no-matching-overload] No overload of bound method `__init__` matches arguments
- Found 47 diagnostics
+ Found 48 diagnostics

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | TypeBlocks | Batch | ... omitted 6 union elements, object_]`
+ static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Index[Any]] | Bottom[Series[Any, Any]] | ... omitted 6 union elements, object_]`
- static_frame/core/node_selector.py:526:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@InterfaceSelectQuartet, Any]`, found `InterGetItemLocReduces[Bottom[Series[Any, Any]] | Unknown, Any]`
+ static_frame/core/node_selector.py:526:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@InterfaceSelectQuartet, Any]`, found `InterGetItemLocReduces[Unknown | Bottom[Series[Any, Any]], Any]`
- static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | Bottom[Index[Any]] | TypeBlocks | ... omitted 6 union elements, TVDtype@Series]`
+ static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | TypeBlocks | Batch | ... omitted 6 union elements, TVDtype@Series]`
- static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | Bottom[Index[Any]] | TypeBlocks | ... omitted 7 union elements, TVDtype@SeriesHE]`
+ static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | TypeBlocks | Batch | ... omitted 7 union elements, TVDtype@SeriesHE]`

rotki (https://github.com/rotki/rotki)
- rotkehlchen/chain/decoding/tools.py:96:44: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- rotkehlchen/chain/decoding/tools.py:99:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `Sequence[A@BaseDecoderTools]`, found `Unknown | tuple[BTCAddress, ...] | tuple[ChecksumAddress, ...] | tuple[SubstrateAddress, ...] | tuple[SolanaAddress, ...]`
- rotkehlchen/chain/decoding/tools.py:100:62: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ rotkehlchen/chain/decoding/tools.py:97:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress`, found `A@BaseDecoderTools`
+ rotkehlchen/chain/decoding/tools.py:98:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress | None`, found `A@BaseDecoderTools | None`
- Found 2084 diagnostics
+ Found 2083 diagnostics

Memory usage changes were detected when running on open source projects
trio (https://github.com/python-trio/trio)
-     struct fields = ~12MB
+     struct fields = ~11MB

@AlexWaygood

This comment was marked as resolved.

@AlexWaygood

This comment was marked as resolved.

@AlexWaygood AlexWaygood force-pushed the alex/enum-literal-unions branch from 24a387e to fc51a96 Compare January 4, 2026 15:32
@AlexWaygood AlexWaygood force-pushed the alex/enum-literal-unions branch from fc51a96 to c62c6c8 Compare January 4, 2026 17:04
@AlexWaygood AlexWaygood changed the base branch from main to alex/new-enum-benchmark January 4, 2026 17:04
@AlexWaygood AlexWaygood closed this Jan 4, 2026
@AlexWaygood AlexWaygood reopened this Jan 4, 2026
@codspeed-hq
Copy link

codspeed-hq bot commented Jan 4, 2026

Merging this PR will improve performance by ×2.2

Summary

⚡ 1 improved benchmark
✅ 22 untouched benchmarks
⏩ 30 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation ty_micro[many_enum_members_2] 340.7 ms 152.5 ms ×2.2

Comparing alex/enum-literal-unions (9186059) with main (7319c37)

Open in CodSpeed

Footnotes

  1. 30 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@AlexWaygood AlexWaygood force-pushed the alex/new-enum-benchmark branch from fa29e3f to a2d8493 Compare January 4, 2026 17:54
Base automatically changed from alex/new-enum-benchmark to main January 4, 2026 17:58
@AlexWaygood AlexWaygood force-pushed the alex/enum-literal-unions branch from c62c6c8 to 1500752 Compare January 4, 2026 18:12
@AlexWaygood AlexWaygood marked this pull request as ready for review January 4, 2026 18:23
@MichaReiser
Copy link
Member

Nice, this is a huge improvement for discord.py (and a small improvement for homeassistant)

❯ uv run benchmark --tool ty --ty-path ~/astral/ruff/target/profiling/ty-main --ty-path ~/astral/ruff/target/profiling/ty
      Built ty-benchmark @ file:///Users/micha/astral/ruff/scripts/ty_benchmark
Uninstalled 1 package in 1ms
Installed 1 package in 1ms
discord.py
----------

Benchmark 1: /Users/micha/astral/ruff/target/profiling/ty-main
  Time (mean ± σ):     242.9 ms ±   5.0 ms    [User: 1423.4 ms, System: 114.8 ms]
  Range (min … max):   238.1 ms … 256.1 ms    12 runs

  Warning: Ignoring non-zero exit code.

Benchmark 2: /Users/micha/astral/ruff/target/profiling/ty
  Time (mean ± σ):     175.7 ms ±   3.1 ms    [User: 1324.9 ms, System: 111.3 ms]
  Range (min … max):   171.6 ms … 181.2 ms    16 runs

  Warning: Ignoring non-zero exit code.

Summary
  /Users/micha/astral/ruff/target/profiling/ty ran
    1.38 ± 0.04 times faster than /Users/micha/astral/ruff/target/profiling/ty-main

-------------------------------------------------------------------------------

homeassistant
-------------

Benchmark 1: /Users/micha/astral/ruff/target/profiling/ty-main
  Time (mean ± σ):      2.218 s ±  0.067 s    [User: 22.101 s, System: 3.110 s]
  Range (min … max):    2.128 s …  2.347 s    10 runs

  Warning: Ignoring non-zero exit code.

Benchmark 2: /Users/micha/astral/ruff/target/profiling/ty
  Time (mean ± σ):      2.116 s ±  0.088 s    [User: 21.415 s, System: 3.123 s]
  Range (min … max):    1.998 s …  2.245 s    10 runs

  Warning: Ignoring non-zero exit code.

Summary
  /Users/micha/astral/ruff/target/profiling/ty ran
    1.05 ± 0.05 times faster than /Users/micha/astral/ruff/target/profiling/ty-main

-------------------------------------------------------------------------------

However, it's also a substantial regression for pytorch. Do you think this is because of the now higher limit?

 uv run benchmark --tool ty --ty-path ~/astral/ruff/target/profiling/ty-main --ty-path ~/astral/ruff/target/profiling/ty --project pytorch
pytorch
-------

Benchmark 1: /Users/micha/astral/ruff/target/profiling/ty-main
  Time (mean ± σ):      1.201 s ±  0.085 s    [User: 11.724 s, System: 1.407 s]
  Range (min … max):    1.113 s …  1.375 s    10 runs

  Warning: Ignoring non-zero exit code.

Benchmark 2: /Users/micha/astral/ruff/target/profiling/ty
  Time (mean ± σ):      1.294 s ±  0.089 s    [User: 12.175 s, System: 1.437 s]
  Range (min … max):    1.144 s …  1.423 s    10 runs

  Warning: Ignoring non-zero exit code.

Summary
  /Users/micha/astral/ruff/target/profiling/ty-main ran
    1.08 ± 0.11 times faster than /Users/micha/astral/ruff/target/profiling/ty

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Jan 5, 2026

However, it's also a substantial regression for pytorch. Do you think this is because of the now higher limit?

Hmm, we currently have no limit at all for enum literals on main, so I don't think that can be the cause. The more probable cause is that by rewriting all our enum-literal logic in the union builder to add the new fast path for pathological cases where there are many enum literals in the union, I also made some cases slower. (Our existing logic for enum Literals in the union builder is non-trivial; it's very possible I accidentally got rid of an optimisation we already had on main.)

@AlexWaygood AlexWaygood force-pushed the alex/enum-literal-unions branch 2 times, most recently from fade96f to 910630d Compare January 5, 2026 14:06
@AlexWaygood
Copy link
Member Author

@MichaReiser, I can reproduce the speedup on discord.py locally but I can't reproduce the slowdown on pytorch. When I run ty_benchmark, it reports a 3% speedup for this PR on pytorch:

pytorch
-------

Benchmark 1: /Users/alexw/dev/ruff/target/profiling/ty-main
  Time (mean ± σ):      1.230 s ±  0.073 s    [User: 12.440 s, System: 1.419 s]
  Range (min … max):    1.158 s …  1.371 s    10 runs
 
  Warning: Ignoring non-zero exit code.
 
Benchmark 2: /Users/alexw/dev/ruff/target/profiling/ty
  Time (mean ± σ):      1.196 s ±  0.055 s    [User: 12.499 s, System: 1.374 s]
  Range (min … max):    1.149 s …  1.294 s    10 runs
 
  Warning: Ignoring non-zero exit code.
 
Summary
  /Users/alexw/dev/ruff/target/profiling/ty ran
    1.03 ± 0.08 times faster than /Users/alexw/dev/ruff/target/profiling/ty-main

@MichaReiser
Copy link
Member

Hmm. I did ran the benchmark twice before, but I'm now unable to reproduce. So maybe just a flake on my machine.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

This is great, thank you!!

replace_with.push(KnownClass::Bytes.to_instance(self.db));
}
UnionElement::EnumLiterals { enum_class, .. } => {
replace_with.push(Type::instance(self.db, ClassType::NonGeneric(*enum_class)));
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not aware of anything that prevents enum classes from being generic, so I expect this should not directly construct a ClassType::NonGeneric, but should use a constructor that inspects the class instead. (IIRC we aim to maintain an invariant that we never represent an unspecialized generic class with ClassType::NonGeneric)

Copy link
Member Author

@AlexWaygood AlexWaygood Jan 7, 2026

Choose a reason for hiding this comment

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

The concept of a generic enum class is inherently incoherent.

Apart from that, attempting to create a generic enum class usually fails at runtime:

>>> from enum import Enum
>>> class Foo[T](Enum):
...     X: T
...     
Traceback (most recent call last):
  File "<python-input-1>", line 1, in <module>
    class Foo[T](Enum):
        X: T
  File "<python-input-1>", line 1, in <generic parameters of Foo>
    class Foo[T](Enum):
        X: T
  File "/Users/alexw/.pyenv/versions/3.13.1/lib/python3.13/enum.py", line 491, in __prepare__
    member_type, first_enum = metacls._get_mixins_(cls, bases)
                              ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/Users/alexw/.pyenv/versions/3.13.1/lib/python3.13/enum.py", line 956, in _get_mixins_
    raise TypeError("new enumerations should be created as "
            "`EnumName([mixin_type, ...] [data_type,] enum_type)`")
TypeError: new enumerations should be created as `EnumName([mixin_type, ...] [data_type,] enum_type)`
>>> from typing import Generic, TypeVar
>>> T = TypeVar("T")
>>> class Foo(Enum, Generic[T]):
...     X: T
...     
Traceback (most recent call last):
  File "<python-input-4>", line 1, in <module>
    class Foo(Enum, Generic[T]):
        X: T
  File "/Users/alexw/.pyenv/versions/3.13.1/lib/python3.13/enum.py", line 491, in __prepare__
    member_type, first_enum = metacls._get_mixins_(cls, bases)
                              ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
  File "/Users/alexw/.pyenv/versions/3.13.1/lib/python3.13/enum.py", line 956, in _get_mixins_
    raise TypeError("new enumerations should be created as "
            "`EnumName([mixin_type, ...] [data_type,] enum_type)`")
TypeError: new enumerations should be created as `EnumName([mixin_type, ...] [data_type,] enum_type)`

even when the class creation does not immediately fail at runtime, it does not work in the way you'd expect, because of the fact that Generic.__class_getitem__ is clobbered by EnumMeta.__getitem__:

>>> from enum import Enum
>>> from typing import Generic, TypeVar
>>> T = TypeVar("T")
>>> class Foo(Generic[T], Enum):
...     X: T
...     
>>> Foo[int]
Traceback (most recent call last):
  File "<python-input-6>", line 1, in <module>
    Foo[int]
    ~~~^^^^^
  File "/Users/alexw/.pyenv/versions/3.13.1/lib/python3.13/enum.py", line 789, in __getitem__
    return cls._member_map_[name]
           ~~~~~~~~~~~~~~~~^^^^^^
KeyError: <class 'int'>

Generic enums are explicitly forbidden by mypy and pyrefly:

and while we do not yet explicitly forbid them, we also make it impossible for generic enums to exist in our model due to the way we represent enum-literal types -- EnumLiteralType wraps a ClassLiteral rather than a ClassType:

#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)]
#[derive(PartialOrd, Ord)]
pub struct EnumLiteralType<'db> {
/// A reference to the enum class this literal belongs to
enum_class: ClassLiteral<'db>,
/// The name of the enum member
#[returns(ref)]
name: Name,
}

So I do not believe that generic enums are a concept that we can or should support, and I would instead view this as a missing lint that we should implement in a separate PR (where we explicitly forbid enum classes to multiple-inherit from Generic[] or have PEP-695 type parameters)

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair enough, that works for me! Looks like I just picked the wrong type checker to test in (pyright supports generic enums to at least some extent.) The one part of an enum that could be coherently generic is methods, maybe? But I'm happy to just say we don't support generic enums and never will.

// for why we avoid using the `should_widen` closure here.
let enum_literals_limit =
if self.recursively_defined.is_yes() && cycle_recovery {
MAX_RECURSIVE_UNION_LITERALS
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we have any tests involving a recursively-defined union with more than 10 enum elements in it? (Or one with less than 10, for that matter). Could probably look at the PR that introduced this constant to find similar tests for other kinds of literals, to work from.

Copy link
Member Author

Choose a reason for hiding this comment

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

It doesn't look to me like we have any tests for recursively defined unions with lots of Literal elements in them. I looked at c7b5067, which originally introduced the size limit on the number of Literal elements we allow to coexist in a union before widening the Literals, and f3e5713, which increased the limit for non-recursive unions -- neither commit appeared to include tests of the kind you're asking about here.

Happy to look into this more as a followup if you think it's important, but for now I'm going to move on!

};
if literals.len() >= enum_literals_limit {
let replace_with =
Type::instance(self.db, ClassType::NonGeneric(enum_class));
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment here about ClassType::NonGeneric.

It seems like this (make an instance type out of an enum class and add it in place) is something we do a lot of different places in this diff. I'm not sure it can be abstracted smaller than the two lines to do those two things, but it would be nice if this particular repeated line didn't repeat non-germane assumptions about genericness in any way (whether they are correct assumptions or not.)

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, I can extract this out into a method on EnumLiteralType

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, we actually already have the helper method you're asking for, I just didn't know about it/forgot about it:

impl<'db> EnumLiteralType<'db> {
pub(crate) fn enum_class_instance(self, db: &'db dyn Db) -> Type<'db> {
self.enum_class(db).to_non_generic_instance(db)
}
}

@AlexWaygood AlexWaygood force-pushed the alex/enum-literal-unions branch from 910630d to da75161 Compare January 7, 2026 22:24
@AlexWaygood AlexWaygood enabled auto-merge (squash) January 8, 2026 10:46
@AlexWaygood AlexWaygood merged commit eeac2bd into main Jan 8, 2026
47 checks passed
@AlexWaygood AlexWaygood deleted the alex/enum-literal-unions branch January 8, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Potential performance improvement ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants