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:
Literal | StrEnum | |
|---|---|---|
| member added, branch missing | caught | caught |
| bad value written in source | caught | not caught |
| bad value arriving at runtime | not caught | caught (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
strof the form<class.member: value>. That isrepr. AndStrEnum, the notebook’s own example, is precisely where it fails, since it keepsstr.__str__. - “No accidental truthiness” doesn’t survive
StrEnumeither. A member is astr, soDirection.NORTH == 'north', a raw'north'sails through thematchand gets a real answer, and a typo’d raw string returnsNone. 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 reportsType 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 theLiteralcall it suppresses a real error; onDirection('northe')it has nothing to suppress, so ty emitswarning[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
ClassVaris 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 * 2printed0and proved nothing.EAST * 2prints180, 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
- Consider promoting the
Literal/StrEnum/Enumsplit to an atomic note. The “enums give you static safety” intuition is common enough to deserve a claim-titled note. - Check whether the
pythonskill recommends enums anywhere on static-analysis grounds that this contradicts. - Carry over the open items from the JupyterLab entry (stale
user-settingsfiles, HM search-path audit).
Related
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