Records, Enums and Sealed Types
Java's compact data types: records for immutable data, enums for fixed sets of values, sealed classes and interfaces, and record patterns in switch.
Records
Much of the code in business applications moves data around: a meter
reading, a tariff, an invoice line. Written as an ordinary
class, each of those needs
private final fields, a constructor, getters, and
equals, hashCode and
toString: dozens of lines that say very little. A
record declares the same thing in
one line. You list the components in the header, and the compiler
generates the rest.
import java.time.LocalDate;
public class Main {
public static void main(String[] args) {
MeterReading r = new MeterReading("E-001", LocalDate.of(2026, 10, 1), 18745);
MeterReading same = new MeterReading("E-001", LocalDate.of(2026, 10, 1), 18745);
System.out.println(r);
System.out.println(r.meterId() + " on " + r.date() + ": " + r.kwh() + " kWh");
System.out.println(r.equals(same));
System.out.println(r == same);
System.out.println(r.hashCode() == same.hashCode());
}
}
record MeterReading(String meterId, LocalDate date, int kwh) {}
MeterReading[meterId=E-001, date=2026-10-01, kwh=18745] E-001 on 2026-10-01: 18745 kWh true false true
For each component the compiler generates:
- a
private finalfield with the same name; -
a public accessor method with the same name:
kwh(), notgetKwh(); - a canonical constructor that takes all components in header order;
-
equalsandhashCodebased on all components, and atoStringin the formName[component=value, ...].
Two records with the same values are equal, which is exactly what
you want for data, and they work as keys in a
HashMap without extra code. == still
compares references, as for any object.
Records are immutable
The fields of a record are final: once the constructor
has run, they can never change, not even from inside the record. A
meter reading, once taken, should not be edited.
public class Main {
public static void main(String[] args) {
MeterReading r = new MeterReading("E-001", 18745);
r.reset();
}
}
record MeterReading(String meterId, int kwh) {
void reset() {
kwh = 0;
}
}
Main.java:10: error: cannot assign a value to final variable kwh
From outside, the field is not even visible:
r.kwh = 0 fails with
kwh has private access in MeterReading. Records also
cannot declare extra instance fields (error: field declaration must be static), so the header is always the complete state.
The immutability is shallow, though. A
final field that refers to a mutable
ArrayList cannot be pointed at another list, but the
list itself can still change. Make a defensive copy with
List.copyOf, which returns an unmodifiable list:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Integer> values = new ArrayList<>(List.of(310, 295, 342));
Usage q3 = new Usage("E-001", values);
values.add(9999);
System.out.println(q3);
q3.monthly().add(1);
}
}
record Usage(String meterId, List<Integer> monthly) {
Usage {
monthly = List.copyOf(monthly);
}
}
Usage[meterId=E-001, monthly=[310, 295, 342]] Exception in thread "main" java.lang.UnsupportedOperationException
Adding 9999 to the original list no longer affects the record, and
changing the record's own list is refused. The
Usage { ... } block is a compact constructor, the
subject of the next section.
Compact constructors
A record can check and clean up its values before they are stored. A compact constructor is a constructor written without a parameter list: just the record name and a block. Inside it, the parameters (with the component names) are available; you may throw an exception or assign a new value to a parameter. After the block, the compiler assigns the parameters to the fields.
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
Tariff day = new Tariff(" day ", new BigDecimal("0.30"));
System.out.println(day);
new Tariff("Night", new BigDecimal("-0.10"));
}
}
record Tariff(String name, BigDecimal pricePerKwh) {
Tariff {
if (pricePerKwh.signum() < 0) {
throw new IllegalArgumentException("negative price for " + name.strip() + ": " + pricePerKwh);
}
name = name.strip().toUpperCase();
}
}
Tariff[name=DAY, pricePerKwh=0.30] Exception in thread "main" java.lang.IllegalArgumentException: negative price for Night: -0.10
Validation in the compact constructor means no invalid tariff can
exist anywhere in the program. If a component must not be
null, check that here too with
Objects.requireNonNull (see
Null, Optional and NullPointerException).
Records with methods
A record body can hold methods, static fields and static factory methods, and a record can implement interfaces. Because it cannot change, a method that "modifies" a record returns a new one, sometimes called a wither:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class Main {
public static void main(String[] args) {
Tariff day = Tariff.of("Day", "0.30");
Tariff discounted = day.withPrice(day.pricePerKwh().multiply(new BigDecimal("0.90")));
System.out.println(day);
System.out.println(discounted);
System.out.println(day.costFor(350));
}
}
record Tariff(String name, BigDecimal pricePerKwh) {
static Tariff of(String name, String price) {
return new Tariff(name, new BigDecimal(price));
}
Tariff withPrice(BigDecimal newPrice) {
return new Tariff(name, newPrice.setScale(2, RoundingMode.HALF_UP));
}
BigDecimal costFor(int kwh) {
return pricePerKwh.multiply(BigDecimal.valueOf(kwh));
}
}
Tariff[name=Day, pricePerKwh=0.30] Tariff[name=Day, pricePerKwh=0.27] 105.00
What a record cannot do: extend another class (every record
implicitly extends java.lang.Record), be extended
(records are implicitly final), or have extra instance
fields. If you need any of those, write a normal class.
Enums
A Java enum is a class with a
fixed, named set of instances, declared with enum. Each
constant is created once, when the enum is first used, and no code
can create more. That makes an enum the right type for a closed list
of options, such as the status of an outage ticket: the compiler
rejects a typo like Status.RESOVLED, where a string
"resovled" would slip through.
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
Status s = Status.IN_PROGRESS;
System.out.println(s);
System.out.println(s.name() + " " + s.ordinal());
System.out.println(Arrays.toString(Status.values()));
System.out.println(s == Status.IN_PROGRESS);
System.out.println(Status.valueOf("RESOLVED"));
System.out.println(Status.valueOf("Closed"));
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
IN_PROGRESS IN_PROGRESS 1 [OPEN, IN_PROGRESS, RESOLVED] true RESOLVED Exception in thread "main" java.lang.IllegalArgumentException: No enum constant Status.Closed
Every enum gets name() (the constant's name as
written), ordinal() (its position, from 0),
values() (all constants in declaration order) and
valueOf(String), which is case-sensitive and throws for
unknown names. Because each constant exists once, comparing with
== is correct and safe for enums.
Enums with fields and methods
Because an enum is a class, its constants can carry data. Each
constant passes arguments to the enum's constructor, which is always
private: public on an enum constructor is a compile
error (modifier public not allowed here).
public class Main {
public static void main(String[] args) {
for (Priority p : Priority.values()) {
System.out.println(p + " " + p.label() + ": respond within " + p.responseMinutes() + " min");
}
System.out.println(Priority.P2.isUrgent());
}
}
enum Priority {
P1("critical", 15),
P2("high", 60),
P3("medium", 240),
P4("low", 1440);
private final String label;
private final int responseMinutes;
Priority(String label, int responseMinutes) {
this.label = label;
this.responseMinutes = responseMinutes;
}
String label() {
return label;
}
int responseMinutes() {
return responseMinutes;
}
boolean isUrgent() {
return responseMinutes <= 60;
}
}
P1 critical: respond within 15 min P2 high: respond within 60 min P3 medium: respond within 240 min P4 low: respond within 1440 min true
Note the semicolon after the last constant: it ends the list when
more members follow. The JDK also has two collections made for
enums, EnumSet and EnumMap, which are
compact, fast, and iterate in declaration order.
Enums in switch
A
switch expression
over an enum may use the bare constant names, and when it covers
every constant it needs no default. The compiler checks
that, so adding a constant later turns every incomplete switch into
a compile error instead of a silent gap.
public class Main {
public static void main(String[] args) {
System.out.println(next(Status.OPEN));
System.out.println(next(Status.IN_PROGRESS));
System.out.println(next(Status.RESOLVED));
}
static String next(Status s) {
return switch (s) {
case OPEN -> "assign an engineer";
case IN_PROGRESS -> "wait for the fix";
case RESOLVED -> "ask the customer to confirm";
};
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
assign an engineer wait for the fix ask the customer to confirm
Leave out the RESOLVED case, and it no longer compiles:
public class Main {
public static void main(String[] args) {
System.out.println(next(Status.OPEN));
}
static String next(Status s) {
return switch (s) {
case OPEN -> "assign an engineer";
case IN_PROGRESS -> "wait for the fix";
};
}
}
enum Status { OPEN, IN_PROGRESS, RESOLVED }
Main.java:7: error: the switch expression does not cover all possible input values
Sealed interfaces and classes
Some data comes in a fixed number of shapes. A meter reading at
Kestrel Energy is either an electricity reading in kWh or a gas
reading in cubic meters, and nothing else. A
sealed type is an interface or
class (Java 17) that names all of its direct subtypes. A
permits clause
after the type name lists them; any other class that tries to
implement or extend it is a compile error.
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Reading> readings = List.of(
new Electricity("E-001", 350.5),
new Electricity("E-002", 1500.0),
new Gas("G-007", 12.25));
for (Reading r : readings) {
System.out.println(describe(r));
}
}
static String describe(Reading r) {
return switch (r) {
case Electricity e when e.kwh() > 1000 -> e.meterId() + ": high use " + e.kwh() + " kWh";
case Electricity e -> e.meterId() + ": " + e.kwh() + " kWh";
case Gas g -> g.meterId() + ": " + g.cubicMeters() + " m3";
};
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
record Gas(String meterId, double cubicMeters) implements Reading {}
E-001: 350.5 kWh E-002: high use 1500.0 kWh G-007: 12.25 m3
The switch has no default: because
Reading is sealed, the compiler knows that
Electricity and Gas are the only
possibilities, and it checks that both are handled. The
when clause is a guard (see
Control Flow and Switch Expressions). Cases are tried from top to bottom, so the guarded case must
come before the general Electricity e.
A class that is not on the list is refused:
public class Main {
public static void main(String[] args) {
System.out.println(new Water("W-003", 4.5));
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
record Gas(String meterId, double cubicMeters) implements Reading {}
record Water(String meterId, double cubicMeters) implements Reading {}
Main.java:13: error: class is not allowed to extend sealed class: Reading (as it is not listed in its 'permits' clause)
Rules for the permitted subtypes
Every permitted subtype must say how the hierarchy continues below it, with one of three modifiers:
-
final: no further subclasses. Records are implicitly final, which is why the records above need nothing extra, and so are enums (an enum whose constants have their own bodies is implicitly sealed). -
sealed: the subtype is sealed itself, with its ownpermitslist. -
non-sealed: anything may extend it again; the closed list ends there, on purpose.
public class Main {
public static void main(String[] args) {
System.out.println(new Gas("G-007", 12.25));
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
class Gas implements Reading {
String meterId;
double cubicMeters;
Gas(String meterId, double cubicMeters) {
this.meterId = meterId;
this.cubicMeters = cubicMeters;
}
}
Main.java:11: error: sealed, non-sealed or final modifiers expected
When the sealed type and its subtypes are in the same file, as in
these examples, the permits clause may even be left
out: the compiler then takes the subtypes from that file. In real
projects they usually live in separate files of the same package,
and the list is written out.
Record patterns
A record pattern (Java 21)
matches a record and takes it apart in one step:
case Electricity(String id, double kwh) checks the type
and binds each component to a new variable. You can write the
component types or let the compiler infer them with
var, and an unnamed pattern _ (Java 22)
ignores a component you do not need.
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Reading> readings = List.of(
new Electricity("E-001", 350.5),
new Electricity("E-002", 1500.0),
new Gas("G-007", 12.25));
for (Reading r : readings) {
System.out.println(describe(r));
}
}
static String describe(Reading r) {
return switch (r) {
case Electricity(String id, double kwh) when kwh > 1000 -> id + ": high use " + kwh + " kWh";
case Electricity(var id, var kwh) -> id + ": " + kwh + " kWh";
case Gas(var id, _) -> id + ": gas reading";
};
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
record Gas(String meterId, double cubicMeters) implements Reading {}
E-001: 350.5 kWh E-002: high use 1500.0 kWh G-007: gas reading
Record patterns also work with instanceof:
if (r instanceof Gas(var id, var m3)) { ... }. And the
compiler's exhaustiveness check applies to them just as to type
patterns: forget the Gas case, and the switch is
refused.
public class Main {
public static void main(String[] args) {
System.out.println(describe(new Gas("G-007", 12.25)));
}
static String describe(Reading r) {
return switch (r) {
case Electricity(var id, var kwh) -> id + ": " + kwh + " kWh";
};
}
}
sealed interface Reading permits Electricity, Gas {}
record Electricity(String meterId, double kwh) implements Reading {}
record Gas(String meterId, double cubicMeters) implements Reading {}
Main.java:7: error: the switch expression does not cover all possible input values
Modeling variants
The combination of a sealed interface, records and pattern matching
replaces long if/instanceof chains and the
"type code" fields of older code. The subtypes do not all have to be
records; a final class works too, matched with a type
pattern:
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
Tariff[] tariffs = {
new Fixed(new BigDecimal("0.30")),
new DayNight(new BigDecimal("0.34"), new BigDecimal("0.22")),
new Dynamic()
};
for (Tariff t : tariffs) {
System.out.println(t.getClass().getSimpleName() + ": " + explain(t));
}
}
static String explain(Tariff t) {
return switch (t) {
case Fixed(BigDecimal price) -> price + " per kWh, all day";
case DayNight(BigDecimal day, BigDecimal night) -> day + " by day, " + night + " at night";
case Dynamic d -> "hourly market price";
};
}
}
sealed interface Tariff permits Fixed, DayNight, Dynamic {}
record Fixed(BigDecimal price) implements Tariff {}
record DayNight(BigDecimal day, BigDecimal night) implements Tariff {}
final class Dynamic implements Tariff {}
Fixed: 0.30 per kWh, all day DayNight: 0.34 by day, 0.22 at night Dynamic: hourly market price
When Kestrel Energy introduces a fourth kind of tariff, adding it to
the permits list makes the compiler point at every
switch that does not handle it yet. That is the main argument for
sealed types: the compiler finds the places you would otherwise have
to remember. The alternative design, putting a method on each
subtype and letting
polymorphism
choose, is better when new subtypes are added often and new
operations rarely.
Choosing the right kind of type
| You have | Use | Example |
|---|---|---|
| Immutable data, compared by value | record |
Meter reading, invoice line, address |
| A fixed list of named options known at compile time | enum |
Ticket status, priority, weekday |
| A closed set of variants with different data | sealed interface + records |
Electricity or gas reading; tariff kinds |
| Mutable state, identity, behavior that changes it | class |
A connection pool, a running import job |