Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 9 · Core Java
Enums, Records and Sealed Types
How to model data precisely in modern Java: enums for fixed sets of values, records for immutable data carriers, and sealed types with pattern matching for a closed set of alternatives. Each is introduced from the bug it prevents, and compared with the older style you will still see.
For
Learners who know classes and interfaces and want to use modern Java to model fixed sets of values and data types safely.
Level
Before you start
Interfaces, Polymorphism and Composition (article 6) and the equals and hashCode article (7).
On this page
Key takeaways
- An enum is a class with a fixed set of instances. It replaces int or String constants, gives you type checking, and lets the compiler verify that a switch handles every value.
- A record is a concise immutable data carrier. The compiler generates the constructor, accessors, equals, hashCode and toString, and a compact constructor can validate the data.
- Records are shallowly immutable. A record that holds a mutable list or array can still be changed, so copy such components.
- A sealed type permits only a listed set of subtypes. Combined with records and pattern matching for switch, it lets you model alternatives so that the compiler tells you when you have forgotten one.
- Use enums for a closed set of constants, records for plain data, and sealed hierarchies for a closed set of different shapes of data. Use a normal class when objects have identity and change over time.
A job tracker needs to record the stage of every application: applied, screening, interview, offer, rejected, withdrawn. The quick way is to store the stage as a number, or as a string. Then one day somebody writes status = 7, or "Aplied", and it compiles fine and fails in some distant part of the program.
Java has three features for this kind of modelling, and they build on one another. Enums model a fixed set of values. Records model plain data. Sealed types model a fixed set of alternatives, each with its own data. This article introduces them in that order. All three make invalid states harder to write, and make the compiler your assistant.
What you will learn
#- Why constants and strings are a weak way to model a fixed set of values, and how enums fix that.
- How to give an enum fields, methods and behaviour.
- What a record is, what it generates, and what it does not protect.
- What a sealed type is, and how pattern matching for
switchuses it. - How to choose between a class, an enum, a record and a sealed hierarchy.
Enums: a fixed set of values
#Here is the older style that enums replace, with the bug built in:
// File: Main.javapublic class Main { static final int APPLIED = 1; static final int SCREENING = 2; static final int INTERVIEW = 3; static String label(int status) { switch (status) { case APPLIED: return "Applied"; case SCREENING: return "Screening"; case INTERVIEW: return "Interview"; default: return "unknown"; } } public static void main(String[] args) { System.out.println(label(INTERVIEW)); System.out.println(label(7)); // compiles: any int is accepted System.out.println(label(APPLIED + SCREENING)); // meaningless arithmetic also compiles }}InterviewunknownInterviewint constants have no type safety. The compiler cannot tell INTERVIEW from 7 or from APPLIED + SCREENING. There is no way to list all valid values, and printing one gives you a number.
An enum is a special kind of class with a fixed set of instances. Oracle's tutorial puts it this way: use enum types any time you need to represent a fixed set of constants whose values you know at compile time.
// File: ApplicationStatus.javapublic enum ApplicationStatus { APPLIED("Applied", false), SCREENING("Screening", false), INTERVIEW("Interview", false), OFFER("Offer received", true), REJECTED("Rejected", true), WITHDRAWN("Withdrawn", true); private final String label; private final boolean finished; ApplicationStatus(String label, boolean finished) { // enum constructors are private this.label = label; this.finished = finished; } public String label() { return label; } public boolean isFinished() { return finished; } public boolean canMoveTo(ApplicationStatus next) { return switch (this) { case APPLIED -> next == SCREENING || next == REJECTED || next == WITHDRAWN; case SCREENING -> next == INTERVIEW || next == REJECTED || next == WITHDRAWN; case INTERVIEW -> next == OFFER || next == REJECTED || next == WITHDRAWN; case OFFER, REJECTED, WITHDRAWN -> false; }; }}// File: Main.javaimport java.util.EnumMap;import java.util.Map;public class Main { public static void main(String[] args) { for (ApplicationStatus status : ApplicationStatus.values()) { System.out.println(status + " -> " + status.label() + " finished=" + status.isFinished()); } ApplicationStatus current = ApplicationStatus.valueOf("INTERVIEW"); System.out.println(current.canMoveTo(ApplicationStatus.OFFER)); System.out.println(current.canMoveTo(ApplicationStatus.APPLIED)); System.out.println(current == ApplicationStatus.INTERVIEW); Map<ApplicationStatus, Integer> counts = new EnumMap<>(ApplicationStatus.class); counts.put(ApplicationStatus.APPLIED, 4); counts.put(ApplicationStatus.OFFER, 1); counts.put(ApplicationStatus.SCREENING, 2); System.out.println(counts); try { ApplicationStatus.valueOf("Aplied"); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); } }}APPLIED -> Applied finished=falseSCREENING -> Screening finished=falseINTERVIEW -> Interview finished=falseOFFER -> Offer received finished=trueREJECTED -> Rejected finished=trueWITHDRAWN -> Withdrawn finished=truetruefalsetrue{APPLIED=4, SCREENING=2, OFFER=1}rejected: No enum constant ApplicationStatus.ApliedWhat the enum gives you:
- Type safety. A variable of type
ApplicationStatuscan only hold one of the six constants (ornull).label(7)is no longer possible. - A fixed, enumerable set.
values()returns all constants in declaration order, andvalueOf("INTERVIEW")converts a string to a constant, failing loudly for an invalid name. - Behaviour next to the data. The enum has fields, a private constructor and methods.
canMoveToencodes the rules for status changes in one place. - Compiler-checked switches. A
switchexpression over an enum must cover every constant. Add a seventh status and thecanMoveTomethod stops compiling until you handle it, which is the exhaustiveness check from article 3 at work, with nodefaultneeded. - Safe comparison. There is exactly one instance of each constant, so
==is correct for enums (and is also null-safe). - Special collections.
EnumMapandEnumSetare very compact and fast collections keyed by enum constants. Note that the map prints in declaration order.
A few cautions: avoid ordinal() (the constant's position) for anything stored or compared, because inserting a constant in the middle changes every number after it. And when you store an enum in a database, store its name ("INTERVIEW"), not its ordinal, which article 34 returns to. An enum can also implement an interface, and individual constants can override methods, so an enum can carry behaviour (a PaymentMethod enum with a different fee calculation per constant), as long as the set of alternatives is fixed.
Records: data as data
#Many classes exist only to carry values: a point, a currency amount, a search request, a response. In older Java, each one needs a constructor, getters, equals, hashCode and toString, which is a lot of ceremony around no behaviour. Records, final in Java 16 (JEP 395), are designed for this. The JEP describes them as transparent carriers for immutable data, and emphasises that the aim is to model data as data, not merely to cut boilerplate.
One line replaces the whole class:
// File: Main.javaimport java.time.LocalDate;import java.util.ArrayList;import java.util.List;public class Main { record Contact(String name, String email) {} record Application(String company, String role, LocalDate appliedOn) { Application { // compact canonical constructor: validation if (company == null || company.isBlank()) { throw new IllegalArgumentException("company is required"); } if (appliedOn.isAfter(LocalDate.of(2100, 1, 1))) { throw new IllegalArgumentException("date is unrealistic"); } company = company.strip(); // normalise before the fields are assigned } boolean isRecent(LocalDate today) { // records can have methods return !appliedOn.isBefore(today.minusDays(30)); } } record Team(String name, List<String> members) {} public static void main(String[] args) { Contact a = new Contact("Asha", "asha@example.com"); Contact b = new Contact("Asha", "asha@example.com"); System.out.println(a); System.out.println(a.equals(b) + " " + (a.hashCode() == b.hashCode()) + " " + a.name()); Application app = new Application(" Acme Ltd ", "Backend Developer", LocalDate.of(2026, 10, 1)); System.out.println(app); System.out.println(app.isRecent(LocalDate.of(2026, 10, 20))); try { new Application(" ", "Tester", LocalDate.of(2026, 1, 1)); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); } List<String> people = new ArrayList<>(List.of("Ravi")); Team team = new Team("Platform", people); people.add("Meena"); // the record's list changes! System.out.println(team); }}Contact[name=Asha, email=asha@example.com]true true AshaApplication[company=Acme Ltd, role=Backend Developer, appliedOn=2026-10-01]truerejected: company is requiredTeam[name=Platform, members=[Ravi, Meena]]From the single header record Contact(String name, String email) {} the compiler generates:
- private final fields for each component;
- a canonical constructor taking all components;
- accessor methods named after the components (
name(), notgetName()); equalsandhashCodebased on all components, and atoStringthat lists them.
You can add methods, static members and validation. The compact constructor (the one written without a parameter list, as in Application) runs before the fields are assigned, so it is the place to validate and normalise inputs. Records are implicitly final, cannot extend another class (they can implement interfaces), and their fields are final: that is the point.
Records are only shallowly immutable. The last lines show the trap: the record's component is a reference to a mutable ArrayList, and the caller changed that list after the record was built, so the record's apparent state changed. The record cannot protect the contents of objects it holds. Apply the defensive-copy rule from article 7: copy mutable components in the compact constructor with members = List.copyOf(members);.
Use records for DTOs (data transfer objects) in the Spring project, for compound map keys, for return values that group several results, and for value objects. Do not use a record for an object with identity and a mutable lifecycle (an account, a JPA entity), because the whole point of a record is that two with equal data are equal and interchangeable.
Sealed types: a closed set of alternatives
#An enum models a closed set of values. Often what you need to model is a closed set of kinds of thing, each with different data. Consider the events in the life of a job application:
- it was submitted (with a company name),
- an interview was scheduled (with a time and a round),
- it was rejected (with a reason), or
- an offer was received (with an amount).
An enum cannot hold different data per constant. An open interface can, but then anyone can add a fifth kind of event, and a switch over events can never be sure it has covered everything. Sealed types (JEP 409, final in Java 17) fix this. JEP 409 describes the goal: a superclass that is widely accessible but not widely extensible, where the subclasses are restricted to those known to the author.
// File: Main.javaimport java.time.LocalDateTime;public class Main { sealed interface ApplicationEvent permits Submitted, InterviewScheduled, Rejected, OfferReceived {} record Submitted(String company) implements ApplicationEvent {} record InterviewScheduled(LocalDateTime at, int round) implements ApplicationEvent {} record Rejected(String reason) implements ApplicationEvent {} record OfferReceived(long annualAmount) implements ApplicationEvent {} static String describe(ApplicationEvent event) { return switch (event) { case Submitted s -> "Applied to " + s.company(); case InterviewScheduled(LocalDateTime at, int round) -> "Round " + round + " on " + at.toLocalDate(); case Rejected r when r.reason().isBlank() -> "Rejected (no reason given)"; case Rejected r -> "Rejected: " + r.reason(); case OfferReceived o when o.annualAmount() >= 2_000_000 -> "Large offer: " + o.annualAmount(); case OfferReceived o -> "Offer: " + o.annualAmount(); }; } public static void main(String[] args) { ApplicationEvent[] events = { new Submitted("Acme Ltd"), new InterviewScheduled(LocalDateTime.of(2026, 10, 12, 15, 30), 2), new Rejected(""), new Rejected("position filled"), new OfferReceived(2_400_000), new OfferReceived(900_000) }; for (ApplicationEvent event : events) { System.out.println(describe(event)); } }}Applied to Acme LtdRound 2 on 2026-10-12Rejected (no reason given)Rejected: position filledLarge offer: 2400000Offer: 900000Several features work together:
sealed ... permitsdeclares exactly which types may implement the interface. Each permitted subtype must itself befinal(records are),sealed, ornon-sealed.- Pattern matching for
switch(JEP 441, final in Java 21) letscaselabels test types instead of constants.case Submitted smatches and binds the variables. - Record patterns (JEP 440, final in Java 21) deconstruct a record directly in the label:
InterviewScheduled(LocalDateTime at, int round)pulls out the components with no accessor calls. - Guards: a
whenclause adds a condition to a pattern, as incase Rejected r when r.reason().isBlank(). Order matters: the guarded case must come before the general one, and the compiler rejects an unreachable order. - Exhaustiveness without
default. Because the interface is sealed, the compiler knows the full list of subtypes and checks that theswitchcovers them all.
That last point is the payoff. Watch what happens when someone adds a fifth kind of event but forgets to update describe:
// File: Main.javapublic class Main { sealed interface Event permits Created, Closed, Reopened {} record Created() implements Event {} record Closed() implements Event {} record Reopened() implements Event {} static String describe(Event event) { return switch (event) { case Created c -> "created"; case Closed c -> "closed"; }; } public static void main(String[] args) { System.out.println(describe(new Created())); }}Main.java:12: error: the switch expression does not cover all possible input values return switch (event) { ^1 errorThe compiler reports that the switch expression does not cover all possible input values. You have found the missing case at compile time, instead of finding out in production, which is exactly what you want from modelling with sealed types. If the hierarchy had been an ordinary open interface with a default branch, the new event would have been handled by the default, silently and wrongly.
The sealed approach is sometimes described as modelling with sum types (a value is one of several alternatives) combined with product types (records, which hold several fields). You do not need the terms to use the idea, but you will meet them in articles and interviews.
Choosing the right tool
#You want to model | Use | Example |
|---|---|---|
A fixed set of named values |
|
|
Plain immutable data |
|
|
One of several kinds of data, closed set |
|
|
One of many kinds, open to extension by others | An ordinary interface |
|
Something with identity that changes over time | An ordinary class |
|
Common mistakes
#- Using
ordinal()for storage or comparison. - Storing enum values as numbers in a database or JSON, making reordering dangerous.
- Adding a
defaultbranch to a switch over a sealed type or an enum, which turns off the compiler's exhaustiveness check and hides missing cases. - Assuming a record is deeply immutable.
- Using records for entities with identity and mutable state.
- Putting a more general pattern before a more specific one, which makes the specific case unreachable.
- Using a sealed type when third parties must be allowed to add variants. Sealing is a deliberate restriction.
Exercises
#Exercise 1
Replace the constants
#A program uses static final int LOW = 1, MEDIUM = 2, HIGH = 3 for ticket priority. Rewrite it as an enum with a method boolean isUrgent() that is true for HIGH, and explain two bugs the enum prevents.
Exercise 2: Write a record
#Write a record Money(long paise, String currency) whose constructor rejects negative amounts and blank currencies, with a method Money plus(Money other) that fails if the currencies differ.
Exercise 3: Find the bug
#A record Cart(List<String> items) is created with new Cart(list). Later code calls list.clear() and the cart is suddenly empty. Why, and how do you fix it?
Exercise 4: Model alternatives
#Model the result of parsing a number from text as a sealed interface with two records: a successful result carrying the number, and a failure carrying a message. Write a method using a switch expression that turns either into a user-friendly message.
Exercise 5: Spot the problem
#A switch expression over a sealed interface has a default -> "unknown" branch. A teammate adds a new permitted subtype. What happens, and what would have happened without the default?
Show solutionHide solution
public enum Priority { LOW, MEDIUM, HIGH; public boolean isUrgent() { return this == HIGH; }}The enum prevents invalid values (a method that takes a Priority cannot be called with 7) and meaningless operations (LOW + MEDIUM does not compile). It also lets you list all priorities with Priority.values() and print readable names instead of numbers.
Exercise 2
Show solutionHide solution
public record Money(long paise, String currency) { public Money { if (paise < 0) { throw new IllegalArgumentException("amount cannot be negative"); } if (currency == null || currency.isBlank()) { throw new IllegalArgumentException("currency is required"); } } public Money plus(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("currency mismatch"); } return new Money(paise + other.paise, currency); }}Because it is a record, equals, hashCode and toString come for free, and every Money is valid from the moment it is created.
Exercise 3
Show solutionHide solution
The record stores a reference to the caller's list. Records are shallowly immutable: the field cannot be reassigned, but the list it points to can still be changed through the original reference. Fix it with a defensive copy in the compact constructor: public Cart { items = List.copyOf(items); }. Now the cart holds its own unmodifiable snapshot, and later changes by the caller do not affect it.
Exercise 4
Show solutionHide solution
sealed interface ParseResult permits Parsed, Failed {}record Parsed(int value) implements ParseResult {}record Failed(String message) implements ParseResult {}static ParseResult parse(String text) { try { return new Parsed(Integer.parseInt(text.strip())); } catch (NumberFormatException e) { return new Failed("'" + text + "' is not a whole number"); }}static String show(ParseResult result) { return switch (result) { case Parsed p -> "Parsed " + p.value(); case Failed f -> "Problem: " + f.message(); };}The caller must handle both outcomes, and the compiler checks that it does. This style makes failure part of the return type, and is an alternative to exceptions for expected failures, a trade-off that article 10 discusses.
Exercise 5
Show solutionHide solution
With the default, the code still compiles, and the new subtype is silently handled by default -> "unknown", a likely bug that nobody notices. Without the default, the compiler reports that the switch expression does not cover all possible values, and points the developer to every switch that needs a new case. For sealed types and enums, leave out default and let the compiler check you.
Where to go next
#- Next: Exceptions: Handling Errors Properly completes core Java by covering what to do when things go wrong.
- For records as DTOs: Building a REST API with Spring Boot.
- For enums in the database: JPA and Hibernate: The Persistence Model.
Sources & further reading
- Technology
- Software Engineering
- #Java
- #Java backend
- #Java basics
- #Java interview
- #JPA
- #learn Java
- #modern Java
- #OOP
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 6 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Java Essentials for DSA
Guide · Technology
Heaps and Priority Queues
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

