2026-09-06 The Enum Gave Up the Static Check

What I set out to do

Finish notebooks/learning-enums.ipynb: fill in a Python X placeholder, drop an unused import, swap the “before” example from bare str to the honest baseline (a Literal alias), and add cells demonstrating the benefits the intro asserts.

What I actually did

The first jobs were mechanical. Enum landed in 3.4 via PEP 435, StrEnum in 3.11.

The demonstrations are where it turned. The notebook’s closing argument was that enums win on static analysis: add a member and the checker flags the missing branch, and Direction('southe') fails at construction rather than flowing silently onward. I ran four variants through ty 0.0.77 to show that instead of asserting it, and the result inverted the conclusion:

LiteralStrEnum
member added, branch missingcaughtcaught
bad value written in sourcecaughtnot caught
bad value arriving at runtimenot caughtcaught (ValueError)

Literal gets exhaustiveness too, via the implicit return None not being assignable to -> str, the same mechanism the enum relies on. And only Literal rejects 'southe' statically, because Direction('southe') is a perfectly well-typed call to a constructor. So against the honest baseline the enum gives up a static check. It earns its place on runtime validation at boundaries, somewhere to hang behavior, iterability, and a namespace, none of which are type-checking claims.

Two smaller corrections fell out:

  • The intro credited enums with a nicer str of the form <class.member: value>. That is repr. And StrEnum, the notebook’s own example, is precisely where it fails, since it keeps str.__str__.
  • “No accidental truthiness” doesn’t survive StrEnum either. A member is a str, so Direction.NORTH == 'north', a raw 'north' sails through the match and gets a real answer, and a typo’d raw string returns None. That is the exact failure the enum was supposed to prevent.

Ended at 10 cells (from 5, via an over-long 14 that I cut back after being pushed on verbosity), with the runtime claims written as asserts so they verify themselves rather than printing prose to be eyeballed.

The static half got rewritten twice. First version shelled out to ty over four synthetic source strings, roughly 2.5k chars of harness. The better version is inline, because ty checks .ipynb natively (reporting file:cell N:line:col) and is already registered as a JupyterLab LSP for text/x-ipython, so diagnostics render as squiggles while reading. Three constructs carry the whole argument:

  • assert_never(direction) closing each real function. Silent when exhaustive, and when not it reports Type Literal["south", "east", "west"] is not equivalent to Never, naming the members you forgot.
  • reveal_type(direction) in the deliberately-broken variants, to show what the checker is holding at that point.
  • The same # ty: ignore[invalid-argument-type] on both spellings of the bad value. On the Literal call it suppresses a real error; on Direction('northe') it has nothing to suppress, so ty emits warning[unused-ignore-comment]. The asymmetry is the finding, expressed in two adjacent lines rather than a paragraph.

One design note worth keeping: reveal_type executes at runtime and prints Runtime type is 'str' to stderr, so leaving it in the live functions polluted every cell that exercised a fall-through. assert_never raises instead of printing, which is why it belongs in the real code and reveal_type belongs only in the throwaway variants.

Going to the sources

Late in the session I stopped reasoning from folklore and read PEP 435, PEP 586, PEP 663, and both docs pages. Three things landed:

PEP 435 makes no static-analysis claim at all. It is from 2013. Its promises are identity comparison, iteration, distinctness “including, importantly, values within other enumerations”, meaningless operations being undefined, a printable repr without “tedious repetition”, and non-enum comparisons always unequal. The exhaustiveness claim I had put first arrives six years later in PEP 586, and it is not about enums alone.

PEP 586 explains my experiment rather than being contradicted by it: “we can instead treat them as being approximately equivalent to the union of their values and take advantage of any existing logic regarding unions, exhaustibility, type narrowing…“. To a checker an enum is a union of literals. Literal and Enum narrowing identically is the spec, not a ty quirk.

The whole notebook was built on the wrong class. The HOWTO says “for the majority of new code, Enum and Flag are strongly recommended, since IntEnum and IntFlag break some semantic promises of an enumeration (by being comparable to integers, and thus by transitivity to other unrelated enumerations)”, and says the same transitivity of StrEnum. So every “caveat” I had documented empirically is a PEP 435 promise that StrEnum breaks on purpose for the replacement-of-existing-constants use case. The sharpest single line: Compass.NORTH == Bearing.NORTH is True across two unrelated StrEnums, and False for plain Enum. PEP 663 tried to tidy the resulting str/repr inconsistencies and was rejected, so it is permanent by design.

Rebuilt with plain Enum as the lead and StrEnum as the contrast, with the claim list taken from the PEP instead of invented.

What was striking

Going to the primary source did not just add citations, it restructured the argument and shortened the work: the PEP hands you the claim list for free, so there is nothing to invent and nothing to defend. I had written and rewritten a four-item list of benefits three times before reading the document that already enumerated them. Read the spec before building the demo.

Separately, the notebook wasn’t wrong so much as benchmarked against a strawman. Bare str is not what anyone defending strings would write, and against the real alternative most of the asserted advantage evaporated. Picking the weakest version of the opposing option yields a conclusion that feels earned and isn’t. Adjacent to Types Catch Vocabulary Drift, Not Disagreement About a Set.

The second thing bit me inside the fix, and it is the same shape as 2026-09-06 JupyterLab Overrides Were Never Read earlier today. My first ty harness printed “no diagnostics” for all four variants. Twice: once because ty reports paths relative to its cwd rather than as passed, and again because under JupyterLab’s environment it emits ANSI escapes even to a pipe, so the prefix match failed on \x1b[1mv.py\x1b[0m. Both produced a clean-looking result manufactured entirely by the parser, indistinguishable from four genuinely clean checks. The only reason I caught it is that I had run the checker in a shell first and knew what the answer should be. Having the expected result before running the harness is what made the broken harness visible, and that is worth doing deliberately rather than by luck.

The correction that mattered most

All of the above is investigation, and the feedback was that I had encoded my own reasoning into what should be a tutorial. Correct. The notebook had become an argument I was having with myself (strawman baselines, “what the enum gives up”, a comparison table) rather than something that teaches enums.

Rebuilt to a teaching order instead: version and PEP lineage, links to the docs, what an enum is in plain words, why you would want one, a small example using one, the same thing written with bare constants, then the benefits worked through one at a time against that contrast. Renamed learning-enums.ipynb to python-enums.ipynb and the heading to # Python Enums.

Nearly everything I had found survives, but as tutorial content rather than argument: Direction(45) raising is “invalid values are rejected”, the cross-enum equality is “members of different enums never collide”, assert_never is “the checker can prove you handled every case”. The investigation was worth doing and mostly did not belong in the artifact.

A second pass on the tutorial fixed four things worth recording as general notebook-writing rules:

  • Don’t duplicate the output in a comment. Cells had # <Direction.NORTH: 0> next to code that printed exactly that. Two sources of truth for one fact, and the reader can’t tell which is authoritative. Prose moved to the markdown cell above; code cells now just run and print.
  • Compare against the strong alternative, not the weak one. The “without an enum” section only showed loose module constants. A class or frozen dataclass of constants is what people actually write, and it fixes the namespace complaint, so the enum has to earn the other six reasons against that. (Corollary worth keeping: a frozen dataclass whose members are all ClassVar is doing nothing a plain class wouldn’t.)
  • An error message is not self-evidently a benefit. Showing Direction(45) raising reads as the enum being obstructive unless you first show the alternative silently returning “Unknown direction.”, and then show how to accept 45 on purpose via _missing_. Cost, then benefit, then escape hatch.
  • Pick demo values that can fail visibly. NORTH * 2 printed 0 and proved nothing. EAST * 2 prints 180, which is plausibly wrong because 180 is a real bearing, and that lands the “meaningless operations” point in one line.

Added a note on why enums can’t be subclassed, sourced from Guido’s python-dev message of 29 Apr 2013 (thread: “Enumeration items: type(EnumClass.item) is EnumClass?”). The reasoning is an isinstance asymmetry. Guido’s own rendering is not isinstance(Color.red, MoreColor) alongside isinstance(MoreColor.yellow, Color), “pretty backwards”. But the sharper framing sits in the same message and is Ethan Furman’s, the one Guido is replying to: type(MoreColor.red) is not MoreColor, so “while red is accessible in MoreColor, it’s actually a Color instance”. Reaching a member through the subclass gets you something that is not an instance of the subclass. Same fact, since Guido wants MoreColor.red is Color.red, but far more intuitive.

Research lesson worth keeping: WebFetch summarised this message twice and both times returned only Guido’s half, so I wrote up the weaker framing and papered over it with a “the subclass is the smaller set” gloss of my own. curl piped through a regex for the <PRE> block got the raw text in one call, and it contained the better quote from a participant the summaries never named. For a primary source I intend to quote, fetch the raw text, not a summary of it.

He took the precedent from Java and concluded “it’s better to disallow subclassing enums”, putting Enum alongside bool as final. PEP 435 carries it as Restricted subclassing of enumerations: “Subclassing an enumeration is allowed only if the enumeration does not define any members.”

Checked the “compare enum members with is, it’s faster” belief against the sources. Half of it holds. Identity is documented: PEP 435’s abstract says “the values can be compared by identity” and the HOWTO’s Comparisons section leads with Color.RED is Color.RED. But no performance claim exists anywhere in PEP 435, the HOWTO, or the library reference (grepped all three raw for fast / faster / performance / speed / efficient / optimiz; the only hits are a BLUE = 'too fast!' example value and an unrelated note about mutable member values being quadratic at creation).

The documented justification turned out to be elsewhere in the HOWTO, in a section a keyword grep for performance terms would never surface: Enum Members (aka instances). “The most interesting thing about enum members is that they are singletons. EnumType creates them all while it is creating the enum class itself, and then puts a custom __new__() in place to ensure that no new ones are ever instantiated by returning only the existing member instances.” That is the real answer: there is exactly one object per member forever, and Direction(90) is a lookup rather than a construction, which is what makes is meaningful instead of merely usually-true.

Lesson: grepping for the word I expected (“fast”, “performance”) found nothing and I concluded the claim was unsourced. Enumerating the document’s section headings and reading the structurally relevant ones found the actual rationale in four sentences. Grep answers “is this word here”; it cannot answer “is this idea here”.

Measured it anyway: is is roughly 2x quicker, but that is ~4ns against ~9ns. Real, irrelevant. And on a plain Enum the two agree regardless, because Enum defines no __eq__ of its own, so == falls through to object.__eq__, which is an identity check.

The actual reason to prefer is is that it keeps meaning the same thing when the enum is not a plain Enum. Status.ACTIVE == "active" is True for a bare string belonging to no enumeration, so == stops being evidence you hold a member. is is not fooled. Written with is from the start, the check survives a later switch to StrEnum.

ty ignore-directive forms, worth remembering: bare # type: ignore suppresses, # ty: ignore[rule-name] suppresses, but a mypy-style # type: ignore[some-code] silently does nothing in ty. A coded suppression that looks right and isn’t.

Nice corroboration: ty reports Class MoreColor cannot inherit from final class Color and Class MyBool cannot inherit from final class bool, using the same word for both, so the bool parallel is not just rhetorical.

Two mechanical notes. Redefining Direction mid-notebook to add methods broke exhaustiveness narrowing for every earlier cell (ty degraded Literal[Direction.WEST] to Direction and flagged a spurious invalid-return-type in the opening example), so the behaviour section needed its own enum name. And the JupyterLab command is runmenu:restart-and-run-all, not kernelmenu: — though its label ends in , meaning a confirmation dialog, which is what consumed the 10s timeout. Exporting the code cells to a .py and running them in a fresh interpreter turned out to be the faster correctness check.

Top 3 tomorrow

  1. Consider promoting the Literal / StrEnum / Enum split to an atomic note. The “enums give you static safety” intuition is common enough to deserve a claim-titled note.
  2. Check whether the python skill recommends enums anywhere on static-analysis grounds that this contradicts.
  3. Carry over the open items from the JupyterLab entry (stale user-settings files, HM search-path audit).

2026-09-06 JupyterLab Overrides Were Never Read · Types Catch Vocabulary Drift, Not Disagreement About a Set · Python Type Checkers Comparison · The Unreasonable Effectiveness of Constructive Data Modeling - Alexis King