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 final field with the same name;
  • a public accessor method with the same name: kwh(), not getKwh();
  • a canonical constructor that takes all components in header order;
  • equals and hashCode based on all components, and a toString in the form Name[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 own permits list.
  • 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