Java · Lesson 12 of 20
Enums, Records and Optional
Model data cleanly in Java with enums (fields, methods, EnumMap), immutable records with compact constructors, and Optional for missing values.
- Intermediate
- 18 min read
- 4 objectives
Before this lessonLesson 11: Interfaces and Abstract Classes
What you will learn
- Replace magic strings and ints with enums that carry data and behaviour
- Use EnumMap, EnumSet and enums in switch
- Write immutable data classes as records with validation
- Return and consume Optional values without null checks
Your Progress
0 of 20 lessons 0%
- Lessons0 / 20
- Completed0
- Est. time left~ 5 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
A lot of Java code exists just to describe data: an order status, a money amount, a user returned from a lookup that might not exist. Older Java handled these with int constants, long hand-written classes and null. Modern Java has three purpose-built tools: enums for a fixed set of choices, records for immutable data, and Optional for "there might be no value".
You met Optional briefly in the streams lesson. Here we look at all three in depth, because together they remove whole categories of bugs.
Enums: a fixed set of named values
Imagine storing an order status as a String. Nothing stops someone typing "shiped". An enum defines every allowed value up front, so the compiler rejects anything else. Each constant is a real object with useful built-in methods: name(), ordinal(), values() and valueOf().
public class Main {
enum Status { PENDING, PAID, SHIPPED, DELIVERED }
public static void main(String[] args) {
Status s = Status.PAID;
System.out.println(s);
System.out.println(s.name() + " is at position " + s.ordinal());
System.out.println(s == Status.PAID); // enums are safe to compare with ==
for (Status each : Status.values()) {
System.out.print(each + " ");
}
System.out.println();
Status parsed = Status.valueOf("SHIPPED");
System.out.println(parsed.compareTo(Status.PENDING) > 0);
}
}PAID PAID is at position 1 true PENDING PAID SHIPPED DELIVERED true
Enums with fields and methods
Enums are full classes. Give them fields, a constructor (always private) and methods, so the data lives next to the value it belongs to instead of in a separate switch somewhere else. You can even give a constant its own override of a method.
public class Main {
enum Plan {
FREE(0, 1),
PRO(12, 5),
TEAM(40, 50) {
@Override
String label() { return "Team (billed yearly)"; }
};
private final int monthlyPrice;
private final int seats;
Plan(int monthlyPrice, int seats) {
this.monthlyPrice = monthlyPrice;
this.seats = seats;
}
int yearlyPrice() { return monthlyPrice * 12; }
String label() { return name().charAt(0) + name().substring(1).toLowerCase(); }
}
public static void main(String[] args) {
for (Plan p : Plan.values()) {
System.out.println(p.label() + ": $" + p.yearlyPrice() + "/year, " + p.seats + " seats");
}
}
}Free: $0/year, 1 seats Pro: $144/year, 5 seats Team (billed yearly): $480/year, 50 seats
Enums in switch, EnumMap and EnumSet
Enums pair naturally with switch. With the arrow form (covered fully in the Modern Java lesson), the compiler checks that a switch expression handles every constant. EnumMap and EnumSet are specialised collections that are faster and more compact than HashMap or HashSet when the keys are enum values, and they keep declaration order.
import java.util.*;
public class Main {
enum Status { PENDING, PAID, SHIPPED, DELIVERED }
static String message(Status s) {
return switch (s) {
case PENDING -> "Waiting for payment";
case PAID -> "Preparing your order";
case SHIPPED, DELIVERED -> "On its way or already there";
};
}
public static void main(String[] args) {
System.out.println(message(Status.PAID));
Map<Status, Integer> counts = new EnumMap<>(Status.class);
List<Status> orders = List.of(Status.PAID, Status.PENDING, Status.PAID, Status.SHIPPED);
for (Status s : orders) counts.merge(s, 1, Integer::sum);
System.out.println(counts);
Set<Status> open = EnumSet.range(Status.PENDING, Status.SHIPPED);
System.out.println(open + " " + open.contains(Status.DELIVERED));
}
}Preparing your order
{PENDING=1, PAID=2, SHIPPED=1}
[PENDING, PAID, SHIPPED] falseRecords: immutable data in one line
A class that just carries data traditionally needs private final fields, a constructor, getters, equals, hashCode and toString: easily 40 lines. A record (standard since Java 16) declares the components once and the compiler generates all of that. Fields are final, so records are immutable. Accessors use the component name without a get prefix.
import java.util.*;
public class Main {
record Point(int x, int y) {}
record User(long id, String email) {}
public static void main(String[] args) {
Point a = new Point(3, 4);
Point b = new Point(3, 4);
System.out.println(a);
System.out.println(a.x() + ", " + a.y());
System.out.println(a.equals(b) + " " + (a.hashCode() == b.hashCode()));
Set<User> users = new HashSet<>();
users.add(new User(1, "ada@example.com"));
users.add(new User(1, "ada@example.com")); // equal, so not added twice
System.out.println(users.size());
}
}Point[x=3, y=4] 3, 4 true true 1
Compact constructors, methods and copies
Records can validate and normalise their data in a compact constructor (no parameter list, the fields are assigned automatically at the end). They can also have instance methods and static factory methods. Because a record cannot change, you "modify" one by returning a new copy, often called a wither.
public class Main {
record Money(long cents, String currency) {
Money {
if (cents < 0) throw new IllegalArgumentException("negative amount");
currency = currency.toUpperCase();
}
static Money of(double amount, String currency) {
return new Money(Math.round(amount * 100), currency);
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
Money withCents(long newCents) { return new Money(newCents, currency); }
@Override
public String toString() { return String.format("%d.%02d %s", cents / 100, cents % 100, currency); }
}
public static void main(String[] args) {
Money price = Money.of(19.99, "usd");
Money total = price.plus(Money.of(5.01, "USD"));
System.out.println(price + " + 5.01 = " + total);
System.out.println(total.withCents(100));
try {
new Money(-5, "EUR");
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}19.99 USD + 5.01 = 25.00 USD 1.00 USD rejected: negative amount
Optional: making missing values explicit
Returning null for "not found" is the source of countless NullPointerExceptions, because nothing in the method signature warns the caller. Returning Optional<User> makes the possibility visible and gives the caller tools to handle it: map transforms the value if present, filter keeps it only if it matches, orElse supplies a default, orElseThrow fails loudly and ifPresentOrElse runs one of two actions.
import java.util.*;
public class Main {
record User(long id, String name, String email) {}
static final List<User> USERS = List.of(
new User(1, "Ada", "ada@example.com"),
new User(2, "Linus", null));
static Optional<User> findById(long id) {
return USERS.stream().filter(u -> u.id() == id).findFirst();
}
public static void main(String[] args) {
System.out.println(findById(1).map(User::name).orElse("unknown"));
System.out.println(findById(9).map(User::name).orElse("unknown"));
String email = findById(2)
.map(User::email) // null here becomes an empty Optional
.orElse("no email on file");
System.out.println(email);
findById(1).filter(u -> u.name().startsWith("A"))
.ifPresentOrElse(u -> System.out.println("found " + u.name()),
() -> System.out.println("nobody"));
try {
findById(9).orElseThrow(() -> new NoSuchElementException("user 9 not found"));
} catch (NoSuchElementException e) {
System.out.println(e.getMessage());
}
}
}Ada unknown no email on file found Ada user 9 not found
Use Optional as a return type. Avoid it for fields, method parameters and collections (return an empty list instead of Optional<List>). And avoid calling get() without checking: that just moves the crash.
Recap
- Enums replace magic strings and ints; they can hold fields, constructors and methods.
- Use enums in switch expressions, and
EnumMap/EnumSetfor enum-keyed collections. - Records generate constructor, accessors,
equals,hashCodeandtoStringfor immutable data. - Validate in a compact constructor and return new copies instead of mutating.
- Return
Optionalfor maybe-missing results and handle it withmap,orElseandorElseThrow.
// Write your solution here
Finished reading? Mark this lesson complete to track your progress.
