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);
    }
}
Output
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");
        }
    }
}
Output
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));
    }
}
Output
Preparing your order
{PENDING=1, PAID=2, SHIPPED=1}
[PENDING, PAID, SHIPPED] false

Records: 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());
    }
}
Output
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());
        }
    }
}
Output
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());
        }
    }
}
Output
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/EnumSet for enum-keyed collections.
  • Records generate constructor, accessors, equals, hashCode and toString for immutable data.
  • Validate in a compact constructor and return new copies instead of mutating.
  • Return Optional for maybe-missing results and handle it with map, orElse and orElseThrow.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 13GenericsType-safe containers and methods with type parameters and bounds.