Enums and why they beat constants
The strings lesson listed one thing String is not for: a fixed set of options.
"veg", "jain", "student". A typo in one of those compiles happily and fails
at runtime, or worse, silently takes the default branch.
An enum is a type whose values are a closed, named set. A typo in an enum constant does not compile. That single property is most of the argument.
The problem with strings
String plan = "vegg"; // compiles
if (plan.equals("veg")) { ... } // never true, no error anywhere
Nothing in the type system knows that plan is supposed to be one of four
things. Every method taking it has to guess, validate, or trust.
enum Plan { VEG, JAIN, STUDENT, TRIAL }
Plan plan = Plan.VEGG; // error: cannot find symbol
The mistake is now a build failure. Push errors from runtime to compile time whenever the language lets you — it is the single most valuable habit static typing offers, and enums are the cheapest place to practise it.
The free machinery
Plan p = Plan.STUDENT;
System.out.println(p);
System.out.println(p.name() + " ordinal=" + p.ordinal());
System.out.println(Arrays.toString(Plan.values()));
System.out.println(Plan.valueOf("JAIN"));
STUDENT
STUDENT ordinal=2
[VEG, JAIN, STUDENT, TRIAL]
JAIN
| Member | Gives |
|---|---|
values() |
An array of every constant, in declaration order |
valueOf("JAIN") |
The constant with that exact name |
name() |
The constant's name as declared |
ordinal() |
Its position, from 0 |
toString() |
The name, unless you override it |
compareTo |
Ordering by ordinal() |
valueOf is case-sensitive and throws on anything unknown:
threw: No enum constant Plan.veg
That message is worth recognising. It nearly always means data from a file, a
database or an API has arrived in a case or spelling your enum does not use —
which is why a parser usually wants a static factory that tries
valueOf(s.strip().toUpperCase()) and fails with a message naming the field.
Two more properties, both useful:
- Enum constants are singletons, so
==is safe and correct for them. You do not need.equals(), and==is null-safe, which.equals()is not. - Enums can be used in
switchwithout qualifying the constant:case VEG ->, notcase Plan.VEG ->.
Constants can carry data
This is where enums stop being a tidy list and start being useful.
enum Plan {
VEG("Vegetarian", 8_235),
JAIN("Jain (no onion)", 9_100),
STUDENT("Student", 7_412),
TRIAL("Trial", 0);
private final String label;
private final int pricePaise;
Plan(String label, int pricePaise) {
this.label = label;
this.pricePaise = pricePaise;
}
String label() { return label; }
int pricePaise() { return pricePaise; }
}
VEG Vegetarian Rs 82.35
JAIN Jain (no onion) Rs 91.00
STUDENT Student Rs 74.12
TRIAL Trial Rs 0.00
The constructor is implicitly private — you cannot create new constants. The
semicolon after the last constant is required once anything follows it, and
forgetting it produces a baffling error.
A price list that lives with the type it describes cannot drift out of sync
with it. The alternative — an enum plus a Map<Plan, Integer> of prices
somewhere else — has two places to update and one of them gets forgotten.
Behaviour per constant
A constant can override a method, which gives you a state machine in a dozen lines:
enum Status {
ORDERED { @Override Status next() { return COOKING; } },
COOKING { @Override Status next() { return OUT_FOR_DELIVERY; } },
OUT_FOR_DELIVERY { @Override Status next() { return DELIVERED; } },
DELIVERED { @Override Status next() { return DELIVERED; } };
abstract Status next();
}
ORDERED -> next: COOKING
COOKING -> next: OUT_FOR_DELIVERY
OUT_FOR_DELIVERY -> next: DELIVERED
DELIVERED -> next: DELIVERED
The compiler forces every constant to supply next(). Add CANCELLED later and
the build fails until you have decided what it does — which is precisely the
reminder you want.
For two or three constants this is elegant. For a dozen it becomes hard to read,
and a switch inside a normal method is clearer. Judge by how much code each
constant contributes.
Exhaustive switch: the payoff
static String describe(Plan plan) {
return switch (plan) {
case VEG -> "standard vegetarian";
case JAIN -> "no onion or garlic";
case STUDENT -> "10% off with an ID card";
case TRIAL -> "three days free";
};
}
No default, and that is deliberate. Remove one case and the build fails:
error: the switch expression does not cover all possible input values
return switch (plan) {
^
Now add a fifth plan to the enum. Every switch that does not handle it stops compiling, and the compiler has just written your to-do list.
Add a default -> "unknown" and you throw that away: the new plan silently
becomes "unknown" everywhere, in production, with no warning. Omit default
on an enum switch unless you genuinely want unlisted values ignored. This is
the best argument in the language for using enums at all, and the sealed types
lesson extends it to whole hierarchies.
EnumMap and EnumSet
Purpose-built collections, and worth knowing they exist:
Map<Plan, Integer> counts = new EnumMap<>(Plan.class);
counts.put(Plan.JAIN, 3);
counts.put(Plan.VEG, 12);
System.out.println(counts);
EnumSet<Plan> discounted = EnumSet.of(Plan.STUDENT, Plan.TRIAL);
System.out.println(discounted + " contains VEG? " + discounted.contains(Plan.VEG));
{VEG=12, JAIN=3}
[STUDENT, TRIAL] contains VEG? false
Both are backed by arrays and bit vectors rather than hashing, so they are faster
and smaller than HashMap and HashSet — and note the EnumMap printed in
declaration order, not insertion order, which is usually what you want for a
report.
The trap: never persist ordinal()
ordinal() is a position, and positions move.
enum Plan { VEG, JAIN, STUDENT, TRIAL }
STUDENT is 2. Store a 2 in your database, then a colleague alphabetises the
enum:
enum Plan { JAIN, STUDENT, TRIAL, VEG }
Every stored 2 now means TRIAL. No exception, no migration, no warning — every
historical record quietly changed meaning, and a free trial is now being billed
at zero.
Persist name(), never ordinal(). Renaming a constant is then a visible,
searchable change instead of a silent one. The same goes for serialising to JSON
or sending over an API: Jackson uses the name by default, and it is right to.
ordinal() is fine for what it is for — ordering within a single run, indexing
an array inside one class. It is never a durable identifier.
When not to use an enum
- When the set is not fixed. Cities you deliver to, product categories a shop edits. If adding a value means a code deploy, an enum is wrong — that is data, and belongs in a database.
- When the values carry a lot of behaviour each. Four constants each with sixty lines of override is a sealed interface with four records, which is the next lesson.
- As a replacement for
booleanon a two-value flag where the names add nothing.enum OnOff { ON, OFF }for a field calledenabledis ceremony. Thoughenum Visibility { PUBLIC, PRIVATE }in a method signature genuinely beats a baretrue, becausesetVisibility(true)is unreadable at the call site.
Check your work
What does an enum give you that a String constant does not? A typo becomes
a compile error rather than a runtime surprise; the set of valid values is part
of the type; and switch can be checked for exhaustiveness.
Why is == safe on enum constants? Each constant is a singleton, so identity
and equality coincide. It is also null-safe, unlike .equals().
What does valueOf("veg") do? Throws
IllegalArgumentException: No enum constant Plan.veg. It is case-sensitive and
matches the declared name exactly.
Why omit default from a switch over an enum? So that adding a constant
breaks every switch that does not handle it. With a default, the new value is
silently swallowed in production.
What is wrong with storing ordinal()? It is a position. Reordering or
inserting a constant silently changes the meaning of every stored value. Store
name().
When is an enum the wrong tool? When the set of values changes without a code deploy — that is data. Or when each constant carries substantial behaviour, which is a sealed hierarchy.
Practice 2, the price list. Putting pricePaise on the enum means there is
exactly one place to change a price, and no way to have a plan with no price. The
Map<Plan, Integer> version compiles fine with a missing entry and returns
null, which unboxes to a NullPointerException somewhere unrelated.
Practice 4, adding a constant. After adding KETO to Plan, every
switch without a default fails to compile, naming each file and line. That is
the feature. With a default -> "unknown" in place, the code compiles and every
keto customer is described as "unknown" until somebody notices — which is the
same class of bug as the ordinal() one: silent, and discovered by a customer.
Practice 6, parsing safely.
static Plan parse(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("plan must not be blank");
}
try {
return Plan.valueOf(raw.strip().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"unknown plan [" + raw + "], expected one of " + Arrays.toString(Plan.values()));
}
}
Listing the valid values in the message is the difference between a bug report
and a fix. The caller pasted Veg with a trailing space; now they can see that.
Practice
-
Replace strings with an enum. Take a program using
"veg","jain"and"student"and convert it. Then try to use a misspelled constant and confirm it will not compile. -
Put the price on the enum. Give each constant a label and a price in paise, with accessors, and print a price list by looping over
values(). Then write theMap<Plan, Integer>version, leave one plan out of the map, and see how far the program gets before failing. -
Write a state machine.
StatuswithORDERED,COOKING,OUT_FOR_DELIVERY,DELIVEREDand anext()per constant. Then addCANCELLEDand let the compiler tell you what you have not decided. -
Prove the exhaustiveness payoff. Write a
switchoverPlanwith nodefault, then add a fifth constant and read the compile error. Then add adefault, add a sixth constant, and notice that nothing at all happens. -
Break
ordinal()on purpose. Print the ordinals, write one down as if storing it, reorder the constants, and print what that stored number now means. One sentence on what would have happened in production. -
Harder — parse a CSV column. Write
Plan.parse(String)handling"veg"," VEG "and"Veg", and throwing a message that names the bad value and lists the valid ones. Then feed it a file where one row in twenty is wrong, and report every bad row with its line number instead of stopping at the first.
Next: sealed types and pattern matching — the Java 21 feature that gives you the enum's exhaustiveness for whole families of classes.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship