Skip to content

Type-Safe Enums — Find the Bug

Category: Resource & Type-Safety Patterns — the recurring ways enum code quietly defeats its own type safety.

12 buggy snippets across Go, Java, Python.


Bug 1: Persisting the Ordinal (Java)

enum Status { PENDING, PAID, SHIPPED }

db.save(order.id, order.status.ordinal());   // BUG: stores 0,1,2

// Later, someone alphabetizes:
enum Status { CANCELLED, PAID, PENDING, SHIPPED }
Status restored = Status.values()[storedOrdinal];   // wrong constant now

Symptoms: After reordering constants, every persisted record maps to the wrong status.

Find the bug `ordinal()` is declaration position. Reordering changes the int→constant mapping while stored bytes stay the same — silent corruption.

Fix

db.save(order.id, order.status.name());                 // store name
Status restored = Status.valueOf(storedName);           // resolve by name

Lesson

Never persist ordinals. Store the name or an explicit stable code.


Bug 2: Stringly-Typed Comparison Sneaks Back In (Python)

class Status(Enum):
    PENDING = "pending"; PAID = "paid"

def is_paid(s: Status) -> bool:
    return str(s) == "paid"   # BUG: str(Status.PAID) == "Status.PAID"

Symptoms: is_paid(Status.PAID) returns Falsestr(enum) is "Status.PAID", not "paid".

Find the bug Comparing the enum's string form to a raw literal reintroduces stringly-typed fragility — and `str(member)` isn't the value anyway.

Fix

def is_paid(s: Status) -> bool:
    return s is Status.PAID   # compare to the enum member

Lesson

Compare enums to enum members (is/==), never to string literals.


Bug 3: Go Accepts an Illegal Value (Go)

type Status int
const ( Pending Status = iota; Paid; Shipped )

func process(s Status) {
    // assumes s is one of the three
}

process(99)   // BUG: compiles and runs

Symptoms: process(99) proceeds with a nonsense status; downstream logic misbehaves.

Find the bug Go's named-int "enum" accepts any int. There's no compile-time closed-set check.

Fix

func process(s Status) error {
    if !s.Valid() { return fmt.Errorf("invalid status %d", s) }
    // ...
    return nil
}
func (s Status) Valid() bool { return s >= Pending && s <= Shipped }

Lesson

In Go, validate enum values at entry. The type alone guarantees nothing.


Bug 4: Blanket default: Hides a New Case (Java)

int discount(Tier t) {
    switch (t) {
        case BRONZE: return 0;
        case SILVER: return 5;
        default:     return 0;   // BUG: GOLD silently gets 0
    }
}
// Later: enum Tier { BRONZE, SILVER, GOLD }

Symptoms: Adding GOLD compiles cleanly; gold customers silently get a 0% discount.

Find the bug The `default:` swallows the new constant. Exhaustiveness checking is disabled by the catch-all.

Fix

int discount(Tier t) {
    return switch (t) {          // switch expression, no default
        case BRONZE -> 0;
        case SILVER -> 5;
        case GOLD   -> 10;
    };
    // Adding a constant now fails to compile until handled.
}

Lesson

Don't use default: over your own closed enum — it disables the compiler's help.


Bug 5: Python Enum Alias Swallows a Member (Python)

class Color(Enum):
    RED = 1
    GREEN = 2
    CRIMSON = 1   # BUG: same value as RED → becomes an alias, not a member

Symptoms: Color.CRIMSON is Color.RED is True; iterating Color yields only RED and GREEN. CRIMSON isn't a distinct member.

Find the bug Two members with the same value: the second is an alias of the first, silently collapsing the intended distinct constant.

Fix

from enum import Enum, unique

@unique          # raises ValueError on duplicate values
class Color(Enum):
    RED = 1
    GREEN = 2
    CRIMSON = 3

Lesson

Use @unique to catch accidental aliasing of enum members.


Bug 6: Parallel Array Keyed by Ordinal (Java)

enum Day { MON, TUE, WED }
static final String[] LABELS = {"Monday", "Tuesday", "Wednesday"};

String label(Day d) { return LABELS[d.ordinal()]; }   // BUG: fragile coupling

Symptoms: Add SUN at the front of Day, and every label shifts by one.

Find the bug The label array is indexed by ordinal. Reordering or inserting constants desyncs labels from days.

Fix

enum Day {
    MON("Monday"), TUE("Tuesday"), WED("Wednesday");
    private final String label;
    Day(String label) { this.label = label; }
    public String label() { return label; }
}

Lesson

Move associated data into the enum; never key a parallel array by ordinal.


Bug 7: Unknown Deserialized Value Masquerades as Valid (Java)

Status status = Status.values()[jsonInt];   // BUG: no bounds check

Symptoms: A producer on a newer version sends jsonInt = 4; values()[4] throws ArrayIndexOutOfBoundsException — or worse, an off-by-one maps to the wrong constant.

Find the bug Trusting external input as an ordinal index. Unknown/forward values aren't handled explicitly.

Fix

Status fromWire(String name) {
    try { return Status.valueOf(name); }
    catch (IllegalArgumentException e) { return Status.UNKNOWN; } // explicit
}

Lesson

Decode unknown values explicitly (UNKNOWN or fail fast). Never index values() with untrusted input.


Bug 8: Go Closure Captures Loop Variable Over Enum (Go)

var handlers []func() Status
for s := Pending; s <= Shipped; s++ {
    handlers = append(handlers, func() Status { return s })   // BUG pre-Go 1.22
}
// every handler returns Shipped+1

Symptoms: All handlers return the same (final) status.

Find the bug Pre-Go 1.22 the loop variable `s` is shared; all closures capture the same `s`, which is past `Shipped` after the loop.

Fix

for s := Pending; s <= Shipped; s++ {
    s := s   // shadow per iteration
    handlers = append(handlers, func() Status { return s })
}

Lesson

Shadow loop variables captured in closures (or use Go 1.22+).


Bug 9: Mutable State on an Enum Constant (Java)

enum Counter {
    INSTANCE;
    private int count = 0;          // BUG: shared mutable global state
    public void inc() { count++; }
}

Symptoms: Because the constant is a singleton, count is global mutable state — racy under concurrency, surprising across the app.

Find the bug Enum constants are effectively global singletons. Mutable fields make them shared mutable global state.

Fix

// Keep enums immutable; store mutable per-thing state elsewhere.
enum Counter { INSTANCE }
// counting belongs in a regular object, not an enum constant.

Lesson

Enum constants should be immutable. They're singletons — mutable fields are global state.


Bug 10: StrEnum Compared to Wrong Case (Python)

from enum import StrEnum

class Color(StrEnum):
    RED = "red"

if user_input == Color.RED:    # user_input = "RED"
    ...                        # BUG: "RED" != "red"

Symptoms: Case-mismatched input silently fails to match, because StrEnum compares by its (lowercase) value.

Find the bug `StrEnum` equals its string value (`"red"`). Comparing to `"RED"` fails. Worse, `StrEnum`'s loose `str` equality invites exactly this stringly-typed slip.

Fix

try:
    color = Color(user_input.lower())   # normalize + parse at boundary
except ValueError:
    raise ValueError(f"unknown color {user_input!r}")

Lesson

Normalize and parse into the enum at the boundary; don't lean on StrEnum's loose equality.


Bug 11: Sealed Interface Not Actually Sealed (Go)

type Shape interface{ Area() float64 }   // BUG: any type can implement this

func describe(s Shape) string {
    switch s.(type) {
    case Circle: return "circle"
    case Rect:   return "rect"
    default:     return "???"   // a new external Shape lands here silently
    }
}

Symptoms: Anyone outside the package can implement Shape; the switch can't be exhaustive and the linter can't help.

Find the bug The interface has only an exported method, so it's open. There's no `isShape()` sealing method limiting implementors to this package.

Fix

type Shape interface { Area() float64; isShape() }   // unexported method seals it
func (Circle) isShape() {}
func (Rect) isShape()   {}

Lesson

Seal a Go sum-type interface with an unexported method; then go-check-sumtype can verify exhaustiveness.


Bug 12: protobuf Enum Zero Value Means Something (proto/Java)

enum Status {
  PENDING = 0;   // BUG: zero value is a meaningful state
  PAID = 1;
}
// A message that never sets status:
Order o = Order.newBuilder().build();
o.getStatus();   // returns PENDING — but it was never set!

Symptoms: Absent status fields silently read as PENDING; "not set" is indistinguishable from "pending."

Find the bug Proto3 uses the enum's zero value as the default for unset fields. Assigning a real state to 0 makes "unset" masquerade as that state.

Fix

enum Status {
  STATUS_UNSPECIFIED = 0;   // reserve zero for "unknown"
  STATUS_PENDING = 1;
  STATUS_PAID = 2;
}

Lesson

In protobuf, the zero value must be UNSPECIFIED so absent fields are detectable.


Practice Tips

  1. Grep for .ordinal() and values()[ — both are persistence/indexing landmines.
  2. Run the exhaustive linter on Go switches; add assert_never in Python.
  3. Test deserialization of an unknown value explicitly.
  4. Apply @unique to Python enums to catch aliasing.
  5. Audit every default: over a closed enum — it usually shouldn't exist.

← Tasks · Resource & Type-Safety · Roadmap · Next: Optimize