Concepts / Object-oriented foundations / Enums versus class hierarchies

Enums versus class hierarchies

A fixed list of kinds, or a family of classes that behave differently.

01The intuition

The problem it solves

A canteen token machine sells three kinds of token: tea, snack and lunch. The first version stores the kind as a string and checks it wherever it matters.

def price(kind: str) -> int:
    if kind == "tea": return 10
    if kind == "snack": return 25
    if kind == "lunch": return 60
    raise ValueError(kind)

def can_buy_before_noon(kind: str) -> bool:
    return kind in ("tea", "snack")

price("Tea")        # ValueError at runtime, a capital letter
price("lunh")       # ValueError, a typo nobody caught

Strings let anything through. A typo is a runtime crash. The list of valid kinds is written in two places and will drift. On the other hand, making a full class per token kind, with TeaToken, SnackToken and LunchToken subclasses, feels like too much machinery for three prices.

The decision is one you make in nearly every design interview: is this a fixed list of named options, which wants an enum, or a family of things that behave differently, which wants a class hierarchy? Choosing wrong either buries behaviour in switch statements or inflates three constants into three files.

Build it one piece at a time

Step 1: make the kinds a closed, spelled-once list. An enum is a type with exactly these members, so a typo is an error the moment the code is read, not when a customer presses a button.

from enum import Enum

class TokenKind(Enum):
    TEA = "tea"
    SNACK = "snack"
    LUNCH = "lunch"

TokenKind("tea")      # TokenKind.TEA
TokenKind("lunh")     # ValueError, but now at the single parsing boundary

Step 2: attach the data each kind carries. Price and the morning rule are facts about a kind, so they live on the member instead of in a function far away.

from enum import Enum

class TokenKind(Enum):
    TEA = (10, True)
    SNACK = (25, True)
    LUNCH = (60, False)

    def __init__(self, price: int, before_noon: bool):
        self.price = price
        self.before_noon = before_noon

TokenKind.LUNCH.price          # 60
TokenKind.TEA.before_noon      # True

Step 3: notice when behaviour starts to differ. Suppose a lunch token now needs a different validation: it must be bought for a specific day and expires at 3 pm, while tea tokens never expire. That is no longer a number per kind; it is different code per kind. Keep the enum for the name, and move the behaviour into a small hierarchy behind one interface.

from abc import ABC, abstractmethod
from datetime import datetime, time

class TokenRule(ABC):
    @abstractmethod
    def valid_at(self, moment: datetime) -> bool: ...

class AlwaysValid(TokenRule):
    def valid_at(self, moment: datetime) -> bool:
        return True

class ExpiresAt(TokenRule):
    def __init__(self, deadline: time): self.deadline = deadline
    def valid_at(self, moment: datetime) -> bool:
        return moment.time() <= self.deadline

RULES = {
    TokenKind.TEA: AlwaysValid(),
    TokenKind.SNACK: AlwaysValid(),
    TokenKind.LUNCH: ExpiresAt(time(15, 0)),
}

Step 4: keep the two tools in their lanes. The enum answers "which one" and carries plain facts; the rule objects answer "how does it behave". A new kind with an existing behaviour is one enum line; a new behaviour is one small class.

SituationRight toolAdding a member costsAdding a behaviour costs
Fixed names with a few facts eachenum with fieldsone linea branch everywhere
Kinds with different code pathsinterface plus classesone classone method on each class
Names plus a few differing behavioursenum plus a map to rule objectsone lineone class, one map entry

Why it works

An enum makes the set of possibilities a fact the compiler and the reader both know, so every switch over it can be checked for completeness and every value is spelled once. Its invariant is "no value outside this list exists". A hierarchy makes behaviour live with the thing that owns it, which is polymorphism; its invariant is "every member answers the shared interface". The failure mode of the enum is the growing switch: once three functions each branch on the kind, you are maintaining a hierarchy by hand. The failure mode of the hierarchy is the empty class: subclasses that differ only by a constant should have been data.

The trade-off you must say out loud

Say the test you applied: "do the kinds differ in data or in code". Then say what changes when the interviewer adds a kind, because they will. An enum keeps related facts on one screen and is easy to serialise and store; a hierarchy keeps related code together and is easy to extend without editing. The hybrid, an enum that maps to behaviour objects, is often the honest answer for a parking lot or a vending machine, and naming it as a deliberate choice earns more than picking either extreme.

Where it shows up in the 100 Google problems

  • Design a parking lot: VehicleSize and SpotSize are enums; fits-or-not is a comparison of data, not a subclass per vehicle.
  • Design a vending machine: coin denominations are an enum with a value; the machine's modes are a hierarchy of state objects because each mode handles the same buttons differently.
  • Design a deck of cards and Blackjack: Suit and Rank are enums carrying the card's point value; the dealer's behaviour is a rule class.
  • Design a JSON parser: token types are an enum for the tokenizer; parsed values are a small hierarchy because objects, arrays and scalars serialise differently.

02Used in these problems