Enums: type-safe constants with behavior
Master Java enums, from simple constant lists to enums with fields, methods, and abstract behavior that replace brittle string and integer constants.
You have seen it a hundred times. A method takes a String parameter called status, and somewhere deep in the codebase someone passes "actve" instead of "active". The compiler does not catch it. Tests pass. The bug surfaces three weeks later when a customer's order gets stuck in limbo because no switch branch matched the typo. Enums exist to kill this entire class of bugs at compile time.
What Are Enums
An enum (short for enumeration) is a type with a fixed set of named constants. Instead of representing order status as a String that could be anything, you define the exact legal values once and let the compiler enforce them everywhere.
Think of a traffic light. It has exactly three states: red, yellow, green. You would never model that as a string ("greenish"?) or an integer (42?). You want a type that says "pick one of these three, and nothing else." That is an enum.
public enum TrafficLight {
RED, YELLOW, GREEN
}
After this declaration, the only legal TrafficLight values are RED, YELLOW, and GREEN. Pass the wrong thing and the compiler stops you before the code ever runs.
The enum approach eliminates an entire category of bugs. No typos, no null confusion, no case sensitivity issues. The compiler knows every legal value and rejects everything else.
Basic Enums
At their simplest, enums are just named constants. You list the values and use them directly.
public enum Direction {
NORTH, SOUTH, EAST, WEST
}
public enum Priority {
LOW, MEDIUM, HIGH, CRITICAL
}
public enum Color {
RED, GREEN, BLUE, YELLOW, BLACK, WHITE
}
Every enum in Java comes with built-in methods for free:
| Method | What it does | Example |
|---|---|---|
name() | Returns the constant name as a String | Direction.NORTH.name() returns "NORTH" |
ordinal() | Returns the zero-based position | Priority.HIGH.ordinal() returns 2 |
values() | Returns an array of all constants | Direction.values() returns all four |
valueOf(String) | Parses a string to the enum | Color.valueOf("RED") returns Color.RED |
A word of caution on ordinal(): never persist it to a database or use it in business logic. If someone reorders the enum constants, every stored ordinal becomes wrong. Use name() or a dedicated field instead.
ordinal() is fragile
Relying on ordinal() for database storage or comparisons is a common trap. Reorder the constants and all your stored data silently breaks. Use name() for serialization, or better yet, add an explicit code field to each constant.
Enums with State and Behavior
Here is where Java enums pull ahead of enums in most other languages. Each constant can carry its own fields, run its own constructor, and expose methods. An enum is not just a label; it is a full object.
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS(4.869e+24, 6.0518e6),
EARTH(5.976e+24, 6.37814e6),
MARS(6.421e+23, 3.3972e6);
private final double mass; // kilograms
private final double radius; // meters
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}
// Behavior: each planet computes its own surface gravity
public double surfaceGravity() {
final double G = 6.67300E-11;
return G * mass / (radius * radius);
}
public double surfaceWeight(double otherMass) {
return otherMass * surfaceGravity();
}
}
Each Planet constant is a singleton instance, constructed once when the class loads. EARTH.surfaceGravity() returns a meaningful computed value, not a magic number buried in a utility class. The data travels with the constant that owns it.
I use this pattern constantly for domain concepts where each constant has distinct data. HTTP status codes with messages, database column types with serializers, file formats with MIME types. If the constant owns data, give it fields. If it owns behavior, give it methods.
Implementation
public enum OrderStatus {
PLACED("Order placed", false),
CONFIRMED("Order confirmed by seller", false),
SHIPPED("Order shipped", false),
DELIVERED("Order delivered", true),
CANCELLED("Order cancelled", true);
private final String description;
private final boolean terminal;
OrderStatus(String description, boolean terminal) {
this.description = description;
this.terminal = terminal;
}
public String description() { return description; }
public boolean isTerminal() { return terminal; }
/**
* Validates whether a transition from this status to 'next' is legal.
* Prevents impossible jumps like DELIVERED -> PLACED.
*/
public boolean canTransitionTo(OrderStatus next) {
if (this.terminal) return false; // terminal states go nowhere
return switch (this) {
case PLACED -> next == CONFIRMED || next == CANCELLED;
case CONFIRMED -> next == SHIPPED || next == CANCELLED;
case SHIPPED -> next == DELIVERED;
default -> false;
};
}
}The OrderStatus enum is the star here. Each constant carries a description and a terminal flag. The canTransitionTo method encodes the state machine directly in the type. You cannot move an order from DELIVERED back to PLACED because the enum says no. No external validation class needed, no scattered if checks across the codebase. The rules live where the data lives.
Enums in Switch Expressions
Java 17+ switch expressions pair beautifully with enums. The compiler knows every constant in the enum, so it enforces exhaustiveness: if you forget a case, the code does not compile.
public String icon(OrderStatus status) {
return switch (status) {
case PLACED -> "π¦";
case CONFIRMED -> "β
";
case SHIPPED -> "π";
case DELIVERED -> "π ";
case CANCELLED -> "β";
};
}
No default branch needed. The compiler verifies that every constant is handled. If you add a new OrderStatus.REFUNDED constant later, every switch expression that does not handle it will fail to compile. That is exactly what you want: the compiler hunts down every spot that needs updating.
Compare this to string-based switching:
// Strings: no exhaustiveness check, silent bugs
public String icon(String status) {
return switch (status) {
case "PLACED" -> "π¦";
case "CONFIRMED" -> "β
";
// Forgot SHIPPED, DELIVERED, CANCELLED... compiles fine
default -> "?";
};
}
The string version compiles happily with missing cases because the default branch swallows them. You only discover the problem when a customer sees "?" on their order page.
Interview signal: exhaustive switch
When discussing state machines or status flows in an interview, mention that Java enum switches are exhaustive. "Adding a new status forces a compile error everywhere it's not handled, so nothing slips through." That signals you think about maintainability, not just correctness today.
Enum State Machine
The OrderStatus.canTransitionTo method defines a state machine. Here is what that looks like visually:
Every arrow is an allowed transition. There is no arrow from DELIVERED to anything because it is a terminal state. The enum's canTransitionTo method is a direct code translation of this diagram.
For your interview: when you need a simple state machine with a small number of states (under 10), encoding the transitions directly in the enum is cleaner than pulling in the full State pattern. Mention this trade-off. "I would use an enum state machine here since we only have five states. If transitions get complex or need to carry side effects, I would switch to the State pattern."
Enums vs Constants vs Strings
Here is the honest comparison. I see teams pick strings or static final ints out of habit, but the trade-offs are not even close.
| Dimension | String constants | static final int | enum |
|---|---|---|---|
| Type safety | None. Any string compiles. | None. Any int compiles. | Full. Compiler rejects invalid values. |
| Typo protection | Zero. "actve" compiles. | N/A (but 42 vs 43 is easy to confuse) | Complete. Misspelled constant fails to compile. |
| Exhaustive switch | No. Needs default. | No. Needs default. | Yes. Compiler enforces all cases. |
| Carries behavior | No. | No. | Yes. Fields, methods, constructors. |
| Namespace | Global unless prefixed. | Global unless prefixed. | Scoped to the type. |
| Serialization | Already a string. | Trivial. | name() or custom field. Need valueOf() for deserialization. |
| Extensibility | Open. Anyone can add a string. | Open. Anyone can add a constant. | Closed. Only defined constants exist. |
| IDE support | Weak. No autocomplete for valid values. | Weak. | Strong. Autocomplete, refactoring, find usages. |
The last row matters more than people think. When you type OrderStatus. and the IDE shows you exactly five options, that is a different development experience from guessing which strings are valid. Code reviews get easier, onboarding gets faster, and refactoring becomes safe.
The closedness of enums is both their strength and their limitation. You cannot add a new value at runtime, which is exactly right for statuses, priorities, and categories. If you need open-ended extensibility (user-defined tags, dynamic categories), enums are the wrong tool.
Common Mistakes
1. Using strings when an enum is the right fit
// Brittle: any string passes the compiler
public void updateStatus(String newStatus) {
if (newStatus.equals("shipped")) { ... }
}
// Better: compiler enforces the valid set
public void updateStatus(OrderStatus newStatus) {
if (newStatus == OrderStatus.SHIPPED) { ... }
}
I see this in codebases where enums were "too much ceremony." The ceremony takes 30 seconds. The debugging session for a typo takes hours. Use enums for any fixed set of values.
2. Adding mutable state to enum constants
// Dangerous: enum constants are shared singletons
public enum Counter {
INSTANCE;
private int count = 0; // mutable field on a singleton!
public void increment() { count++; }
public int getCount() { return count; }
}
Enum constants are singletons, created once when the class loads and shared across the entire application. If you put mutable state on them, every thread sees the same mutable field with no synchronization. This is a concurrency bug waiting to happen. Keep enum fields final.
3. Using ordinal() for persistence
// Today: PLACED=0, CONFIRMED=1, SHIPPED=2
// Someone reorders the enum...
// Now: CONFIRMED=0, PLACED=1, SHIPPED=2
// Every order in the database has the wrong status.
Use name() or a dedicated code string for anything that leaves the JVM (database, API, message queue). Ordinals are an internal implementation detail.
4. Giant enums with dozens of methods
If your enum has 15 constants, each with 200 lines of method overrides, it has outgrown the enum pattern. At that point, refactor to a class hierarchy or the Strategy pattern. Enums work best for small, well-bounded sets.
When enums grow up
If your enum needs to carry complex behavior that varies per constant (different algorithms, different validation rules, different side effects), that is the State pattern or the Strategy pattern asking to be born. Use the enum to select the strategy, not to be the strategy.
Test Your Understanding
Quick Recap
- An enum is a type with a fixed, compiler-enforced set of named constants. Use it anywhere you have a bounded set of values (statuses, priorities, directions).
- Java enums are full objects: they carry fields, constructors, and methods. Each constant is a singleton instantiated at class load time.
- Exhaustive switch expressions on enums force a compile error when you add a new constant, making it impossible to silently miss a case.
- Never persist or compare by
ordinal(). It changes when constants are reordered. Usename()or a dedicated stable field. - Keep enum fields
final. Mutable state on a shared singleton is a concurrency bug. - When an enum's per-constant behavior grows too complex (different algorithms, side effects), refactor to the State or Strategy pattern. The enum stays as the identifier; the pattern handles the behavior.
- For your interview: "I would model this as an enum to make invalid states unrepresentable at compile time" is a signal that you value type safety over convenience.