Type-Safe Enums — Middle Level¶
Category: Resource & Type-Safety Patterns — choose an enum (and design it well) when the set of choices is closed; reach for polymorphism or a registry when it isn't.
Prerequisite: Junior Focus: Why and When
Table of Contents¶
- Introduction
- When to Use a Type-Safe Enum
- When NOT to Use One
- Real-World Cases
- Production-Grade Code
- Enums With Data and Behavior
- The Go Gap and How to Close It
- Serialization & Versioning
- Trade-offs
- Alternatives
- Edge Cases
- Best Practices
- Summary
- Diagrams
Introduction¶
Focus: Why and When
At the junior level, an enum is "a nicer set of constants." At the middle level, the questions are sharper: Is the set genuinely closed? Should the enum carry data or behavior? How do I serialize it without locking my code to constant ordering? What do I do in Go, where the compiler won't help me?
The central judgment: an enum is the right tool for a closed set — values you can enumerate at compile time and that change only when you edit the code. The moment the set must be extended by configuration, plugins, or external data, an enum becomes a liability and polymorphism or a registry wins.
When to Use a Type-Safe Enum¶
- The full set of values is known and fixed at compile time.
- You branch on the value with
switch/matchand want exhaustiveness checking. - The value is passed across module boundaries and you want a self-documenting, illegal-value-proof type.
- Each value may carry associated data (an HTTP status code's number, a planet's mass) or per-value behavior.
When NOT to Use One¶
- The set is open / runtime-extensible. Plugin types, user-defined categories, currencies your config adds — use a registry or polymorphism, not an enum you must recompile to extend.
- The values are genuinely unbounded (a user ID, a country from an external API that may add entries) — model them as data, validated at the boundary.
- Two states that are truly boolean —
enabled/disabledcan be aboolean; reserve enums for sets where named values add clarity.
Real-World Cases¶
java.time.DayOfWeek/Month— closed sets that carry behavior (plus,getDisplayName).- HTTP status families —
1xx…5xxmodelled as an enum carrying the numeric code. - State machines — order lifecycle, connection state; each state knows its legal transitions.
- Feature flags' modes —
OFF,ON,CANARY(a tri-state that a boolean can't express). - Compiler/parser token kinds — a closed set, switched on constantly.
Production-Grade Code¶
Java — enum carrying data¶
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
EARTH (5.976e+24, 6.37814e6),
JUPITER(1.9e+27, 7.1492e7);
private final double mass; // kg
private final double radius; // m
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}
public double surfaceGravity() {
return 6.67300E-11 * mass / (radius * radius);
}
}
double g = Planet.EARTH.surfaceGravity();
The enum is the data table. No parallel array, no risk of mismatched indices.
Python — IntEnum and StrEnum¶
from enum import IntEnum, StrEnum, auto
class Priority(IntEnum): # compares/orders like an int, still type-named
LOW = 1
MEDIUM = 2
HIGH = 3
class Color(StrEnum): # Python 3.11+: behaves like str (JSON-friendly)
RED = "red"
GREEN = "green"
assert Priority.HIGH > Priority.LOW # ordering works
assert Color.RED == "red" # StrEnum compares equal to its value
Use
auto()when the underlying value is irrelevant:RED = auto(). UseStrEnum/IntEnumonly when you want interoperability withstr/int(e.g., JSON, DB) — it deliberately weakens the type boundary.
Enums With Data and Behavior¶
The most powerful form: per-constant behavior — each enum value implements a method differently. This is "strategy-per-constant", a Strategy pattern collapsed into the enum.
public enum Operation {
PLUS { public int apply(int a, int b) { return a + b; } },
MINUS { public int apply(int a, int b) { return a - b; } },
TIMES { public int apply(int a, int b) { return a * b; } };
public abstract int apply(int a, int b);
}
int r = Operation.TIMES.apply(6, 7); // 42
Adding DIVIDE forces you to implement apply — the abstract method makes the new behavior mandatory. This is enums acting as a closed, exhaustive Strategy.
In Python, attach methods directly:
from enum import Enum
class Operation(Enum):
PLUS = "+"
TIMES = "*"
def apply(self, a: int, b: int) -> int:
match self:
case Operation.PLUS: return a + b
case Operation.TIMES: return a * b
The Go Gap and How to Close It¶
Go's iota enum is not type-safe in the way Java's is:
- Any int is assignable.
var s Status = 99compiles. - No exhaustiveness check. A
switchmissing a case compiles silently. - No automatic string form. You must write
String()or generate it.
Closing the gap requires discipline plus tooling:
type Status int
const (
Pending Status = iota
Paid
Shipped
statusEnd // unexported sentinel for bounds checking
)
//go:generate stringer -type=Status
func (s Status) Valid() bool { return s >= Pending && s < statusEnd }
func ParseStatus(raw string) (Status, error) {
switch raw {
case "pending": return Pending, nil
case "paid": return Paid, nil
case "shipped": return Shipped, nil
default: return 0, fmt.Errorf("invalid status %q", raw)
}
}
stringergenerates theString()method.Valid()is your manual exhaustiveness/bounds guard.- Exhaustiveness linters (
exhaustive,go-check-sumtype) flagswitchstatements missing a case — wire them into CI. This is the closest Go gets to compiler-enforced enums.
Serialization & Versioning¶
The single most common production bug with enums: persisting the ordinal.
// DANGEROUS — stores position 0,1,2…
db.save(status.ordinal());
// Later someone alphabetizes the constants:
enum Status { CANCELLED, PAID, PENDING, SHIPPED }
// Now ordinal 0 means CANCELLED, not PENDING. Every stored row is silently wrong.
Rules: - Never serialize ordinal() or rely on declaration order. Persist the name (string) or an explicit, stable code you assign. - Pin an explicit value when storing: give each constant a stable code.
public enum Status {
PENDING("P"), PAID("A"), SHIPPED("S");
private final String code;
Status(String code) { this.code = code; }
public String code() { return code; }
}
- Adding a constant is backward-compatible; removing/renaming one is not. Old serialized data may reference a constant your new code no longer has — handle unknown values explicitly on read (fail fast or map to a known
UNKNOWN).
Trade-offs¶
| Benefit | Cost / Caveat |
|---|---|
| Illegal values unrepresentable | Closed set — recompile to extend |
| Exhaustiveness catches missed cases | A stray default: silences the check |
| Carries data/behavior cleanly | Heavier than a constant; can grow into a god-type |
| Self-documenting, namespaced | Serialization needs an explicit stable mapping |
| Strategy-per-constant | Tight coupling of all behaviors in one file |
Alternatives¶
- Sealed classes / sum types (Kotlin
sealed, Rustenum, Scalasealed trait) — a stronger generalization where each case can carry different data. See Senior and Algebraic Data Types. - Polymorphism — for open sets: an interface with one implementation per type, registered at runtime.
Optional/Result— when the "value" is really "present/absent" or "ok/error", not a category (see Sentinel & Special Values).
Edge Cases¶
- Flag/bitset enums — when values combine (
READ | WRITE). JavaEnumSet, Pythonenum.Flag. Covered in Senior. - Empty enum — legal but pointless; usually a modelling mistake.
- Single-constant enum — Bloch's idiom for a Singleton (
enum Singleton { INSTANCE; }). - Enum with mutable state — an anti-pattern; enum constants are effectively global singletons, so mutable fields are shared mutable global state.
Best Practices¶
- Decide closed vs open first. Closed → enum. Open → polymorphism/registry.
- Let enums hold their associated data instead of parallel maps/arrays keyed by ordinal.
- Persist names or explicit codes, never ordinals.
- Avoid blanket
default:so exhaustiveness checking stays useful. - In Go, pair the type with
stringer, aValid()/parse function, and an exhaustiveness linter.
Summary¶
- Use an enum for a closed set; use polymorphism/registry for an open one.
- Enums can carry data and per-constant behavior (strategy-per-constant).
- Serialization: persist names/explicit codes; ordinals are a time bomb.
- Go needs
stringer+ validation + exhaustiveness linters to approximate type safety. - Python's
IntEnum/StrEnumtrade some type strictness forint/strinterop — use deliberately.
Diagrams¶
← Junior · Resource & Type-Safety · Roadmap · Next: Type-Safe Enums — Senior
In this topic
- junior
- middle
- senior
- professional