Skip to content

Records, Sealed Classes, and Pattern Matching

A record is a transparent carrier for immutable data. The compiler generates the constructor, field Accessors, equals``hashCodeAnd toString from a single declaration. This eliminates the Single largest source of boilerplate in Java: the dumb data class.

public record Point(double x, double y) { }
// Usage
var p = new Point(3.0, 4.0);
p.x(); // 3.0 -- accessor method, not a field
p.y(); // 4.0
System.out.println(p); // Point[x=3.0, y=4.0]

The record components (x``y) are declared in the state space of the class. The compiler Generates a final private field for each component, a public accessor method with the same name (no get prefix), and a canonical constructor that assigns each parameter to its corresponding Field. The accessor is a method, not a field access — this distinction matters for binary Compatibility and for frameworks that rely on JavaBeans conventions.

For record Point(double x, double y)The compiler generates:

public final class Point extends java.lang.Record {
private final double x;
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double x() { return this.x; }
public double y() { return this.y; }
@Override
public boolean equals(Object o) {
return o instanceof Point other
&& Double.compare(this.x, other.x) == 0
&& Double.compare(this.y, other.y) == 0;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return STR."Point[x=\{x}, y=\{y}]";
}
}

The equals implementation uses == for primitive components and Objects.equals for reference Components. This is a structural equality comparison, not identity comparison. The hashCode Implementation is consistent with equals by contract.

A compact constructor lets you validate or normalize components without repeating the parameter List:

public record Age(int value) {
public Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Invalid age: " + value);
}
}
}

The compact constructor does not declare parameters — it implicitly has access to all record Components. Any assignment to a component in the compact constructor is treated as an assignment to The corresponding field in the canonical constructor. This means you can normalize values:

public record NormalizedString(String value) {
public NormalizedString {
value = value.trim().toLowerCase();
}
}

You can override the default accessor to provide computed or transformed values:

public record Circle(double radius) {
public double area() {
return Math.PI * radius * radius;
}
// Override the radius accessor to validate on read
public double radius() {
if (this.radius < 0) {
throw new IllegalStateException("Radius cannot be negative");
}
return this.radius;
}
}

Overriding an accessor does not change the field storage. The field radius is still stored Directly. The accessor intercept is the only change.

Records can have static fields, static methods, and instance methods. They cannot have instance Fields beyond the record components:

public record Money(long cents) {
private static final long MAX_CENTS = Long.MAX_VALUE / 100;
public static Money dollars(long dollars) {
return new Money(dollars * 100);
}
public Money add(Money other) {
return new Money(Math.addExact(this.cents, other.cents));
}
}

Instance fields that are not record components are prohibited because records are shallowly Immutable. Allowing mutable instance state would violate that contract.

Records implement java.io.Serializable if they choose to (by declaring implements Serializable). The serialization mechanism is different from regular classes: the canonical constructor is used for Deserialization, which means validation in the compact constructor is enforced during Deserialization. This closes a long-standing attack vector where malicious serialized data could Bypass constructor validation.

public record AuthToken(String token, long expiryEpochMs) implements Serializable {
public AuthToken {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("Token must not be blank");
}
}
// During deserialization, the compact constructor runs.
// A crafted serialized blob with a blank token will be rejected.
}
FeatureRecordsLombok @Value
Language supportBuilt-in since Java 16Annotation processor (library)
Equals/hashCodeStructural, generatedStructural, generated
MutabilityEnforced immutableEnforced immutable
ExtensibilityCan extend RecordCan extend any class
Compact constructorsYesNo (use @Builder etc.)
Deserialization safetyValidates on readUses reflection, can be bypassed
IDE supportNativeRequires plugin
Framework compatibilityFull (standard Java)May need annotation processing

Records are the standard mechanism going forward. Lombok remains useful in codebases that need Features records do not support (mutable data carriers, builders with complex logic, etc.), but for New code, records should be the default.

Records can be nested inside other records or classes. This is useful for modeling hierarchical Data:

public record Person(String name, Address address) {
public record Address(String street, String city, String zip) { }
}
var p = new Person("Ada Lovelace", new Person.Address("12 King St", "London", "WC2N 5DU"));

Records support generics:

public record Result<T, E>(T value, E error) {
public static <T> Result<T, Void> ok(T value) {
return new Result<>(value, null);
}
public static <E> Result<Void, E> fail(E error) {
return new Result<>(null, error);
}
public boolean isSuccess() {
return error == null;
}
}

A sealed class restricts which other classes or interfaces can extend or implement it. This makes Class hierarchies explicit and enables the compiler to enforce exhaustiveness in pattern matching.

public sealed class Shape
permits Circle, Rectangle, Triangle {
}
public record Circle(double radius) extends Shape { }
public record Rectangle(double width, double height) extends Shape { }
public final class Triangle extends Shape {
private final double sideA, sideB, sideC;
public Triangle(double sideA, double sideB, double sideC) {
this.sideA = sideA;
this.sideB = sideB;
this.sideC = sideC;
}
}

Permitted subclasses must be in the same module (if the sealed class is in a named module) or the Same package (if in the unnamed module). Each permitted subclass must use exactly one of three Modifiers:

  • final — cannot be extended further
  • sealed — continues the sealed hierarchy
  • non-sealed — opens the hierarchy back up for extension
public sealed class Expr
permits Num, Add, Mul, Neg {
// Abstract methods define the contract
public abstract double eval();
}
public record Num(double value) extends Expr {
@Override
public double eval() { return value; }
}
public record Add(Expr left, Expr right) extends Expr {
@Override
public double eval() { return left.eval() + right.eval(); }
}
public record Mul(Expr left, Expr right) extends Expr {
@Override
public double eval() { return left.eval() * right.eval(); }
}
public final class Neg extends Expr {
private final Expr operand;
public Neg(Expr operand) {
this.operand = operand;
}
@Override
public double eval() { return -operand.eval(); }
}

Interfaces can be sealed as well, permitting both classes and interfaces as subtypes:

public sealed interface Codec
permits JsonCodec, XmlCodec, ProtobufCodec {
String encode(Object data);
Object decode(String raw);
}
public final class JsonCodec implements Codec {
@Override
public String encode(Object data) {
return JsonWriter.toJson(data);
}
@Override
public Object decode(String raw) {
return JsonReader.fromJson(raw);
}
}

Before sealed classes, modeling a closed hierarchy required either abstract classes with Package-private constructors (fragile, not enforced by the compiler) or enum singletons (verbose for Stateful types). Sealed classes give you the closed hierarchy guarantee of enums with the Flexibility of regular class hierarchies:

// Before: abstract class with package-private constructor
public abstract class Command {
private Command() { } // Package-private, but not enforced by compiler
public static final class Create extends Command { }
public static final class Update extends Command { }
public static final class Delete extends Command { }
}
// After: sealed class, compiler-enforced
public sealed class Command permits Create, Update, Delete { }
public final class Create extends Command { }
public final class Update extends Command { }
public final class Delete extends Command { }

The sealed version is both more readable and more correct. Any attempt to extend Command from Outside the permitted list is a compile-time error.

Pattern Matching for instanceof (JEP 394, Java 16)

Section titled “Pattern Matching for instanceof (JEP 394, Java 16)”

Pattern matching for instanceof eliminates the need for explicit casting when type-checking. The Pattern variable is bound only if the instanceof test succeeds, and it is in scope only in the Branch where it is guaranteed to be non-null.

// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After
if (obj instanceof String s) {
System.out.println(s.length());
}

The pattern variable s is only in scope inside the if block. The compiler guarantees that s is Non-null when the block executes — the instanceof check already ruled out null.

if (obj instanceof String s && s.length() &gt; 5) {
System.out.println(s.toUpperCase());
}

The pattern variable s is in scope for the remainder of the enclosing expression after it is Introduced. This works with && but not with || — the right operand of || executes when the Left operand is false, which would mean s is not bound.

Pattern variables can be used in ternary expressions and other conditional contexts:

String label = (obj instanceof Number n)
? "Number: " + n.doubleValue()
: "Not a number";

A pattern variable cannot shadow a local variable that is already in scope:

String s = "outer";
if (obj instanceof String s) { // Compile error: s already defined
System.out.println(s);
}

This is intentional — it prevents confusing rebindings where a variable name suddenly refers to a Different value.

Switch expressions are a major evolution of the traditional switch statement. They can return a Value, use arrow syntax, and the compiler can verify exhaustiveness when switching on sealed types Or enums.

String result = switch (status) {
case 200, 201, 204 -> "Success";
case 400 -> "Bad Request";
case 401 -> "Unauthorized";
case 404 -> "Not Found";
case 500 -> "Internal Server Error";
default -> "Unknown";
};

Arrow syntax (->) means “if this case matches, evaluate the expression on the right.” There is no Fall-through. When you need a block with multiple statements, use yield to return a value:

String result = switch (status) {
case 200, 201, 204 -> "Success";
case 400 -> {
log.warn("Bad request received");
yield "Bad Request";
}
default -> {
log.error("Unexpected status: " + status);
yield "Unknown";
}
};

JEP 441 extends switch to work with type patterns, guarded patterns, and record patterns. This is Where sealed classes become truly powerful:

public static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> heronsFormula(t);
// No default needed -- sealed hierarchy is exhaustive
};
}

The compiler knows all permitted subclasses of Shape and verifies that every case is covered. If You add a new Shape subtype and forget to update the switch, it is a compile-time error, not a Runtime bug.

Add a when clause to filter matches:

return switch (shape) {
case Circle c when c.radius() == 0 -> 0.0;
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> heronsFormula(t);
};

Guards are evaluated in order. A case Circle c with no guard must appear after any guarded case Circle c when ... to avoid being unreachable.

Destructure records directly in switch cases:

public static String describe(Expr expr) {
return switch (expr) {
case Num(var value) -> STR."Number: \{value}";
case Add(var left, var right) -> STR."Add(\{left.eval()}, \{right.eval()})";
case Mul(var left, var right) -> STR."Mul(\{left.eval()}, \{right.eval()})";
case Neg(var operand) -> STR."Neg(\{operand.eval()})";
};
}

Record patterns can be nested:

public static String deepDescribe(Expr expr) {
return switch (expr) {
case Add(Num(var a), Num(var b)) -> STR."Sum of two numbers: \{a} + \{b}";
case Add(var left, var right) -> STR."Complex add: \{left} + \{right}";
case Mul(Num(var a), Num(var b)) -> STR."Product of two numbers: \{a} * \{b}";
case Neg(Num(var v)) -> STR."Negation of: \{v}";
case Num(var v) -> STR."Literal: \{v}";
};
}

A traditional switch on null throws NullPointerException. Switch expressions (with JEP 441) Allow an explicit null case:

return switch (shape) {
case null -> throw new IllegalArgumentException("Shape must not be null");
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> heronsFormula(t);
};

When switching on a sealed type, the compiler performs exhaustiveness checking. It considers the Permitted subtypes, their record components, and any default or null cases. This transforms the Visitor pattern from a runtime dispatch mechanism into a compile-time guarantee:

// Traditional Visitor (verbose, runtime dispatch)
interface ShapeVisitor {
void visit(Circle c);
void visit(Rectangle r);
void visit(Triangle t);
}
// Modern pattern matching (concise, compile-time verified)
static void process(Shape shape) {
switch (shape) {
case Circle c -> handleCircle(c);
case Rectangle r -> handleRectangle(r);
case Triangle t -> handleTriangle(t);
}
}

Text blocks provide a way to write multi-line strings without escape sequences for newlines and Quotes. They use triple double quotes as delimiters.

String json = """
{
"name": "Ada Lovelace",
"age": 36,
"languages": ["Java", "Python"]
}
""";

The closing """ determines the indentation. The compiler removes the common leading whitespace From all lines based on the position of the closing delimiter. In the example above, the content is Indented 4 spaces relative to the closing """So 4 spaces are stripped from every line.

Most escape sequences work the same inside text blocks as in regular strings. The key differences:

  • No need to escape " inside a text block — a single " or "" does not terminate the text block. Only """ terminates it.
  • \ at end of line prevents a line break, joining the lines:
    String query = """
    SELECT id, name, email
    FROM users
    WHERE status = 'active'
    ORDER BY \s
    name""";
  • \s translates to a single ASCII space, useful for preserving trailing whitespace that would otherwise be stripped.

Trailing whitespace is stripped. The compiler removes all trailing whitespace from each line in A text block. If you need trailing spaces, use \s:

String table = """
header1 \s
header2 \s
--------\s
value1 \s
value2 \s
""";

Closing delimiter position matters. The closing """ must be on its own line (or on the last Line of content) for the indentation algorithm to work correctly. Placing it at column 0 removes no Indentation:

// 0 indentation stripped (closing """ at column 0)
String a = """
line1
line2
""";
// 4 spaces stripped (closing """ indented 4 spaces)
String b = """
line1
line2
""";

Inconsistent indentation causes errors. If the closing delimiter is more indented than some Content lines, the content lines have negative indentation, which is a compile error:

String bad = """
indented
less indented // Compile error: negative indentation
""";

Modern Java toolbox: Records are like immutable data containers, sealed classes are like controlled access gates, and pattern matching is like smart switches that know what type they’re handling.

Why it matters: Modern Java features make code more concise, safer, and easier to maintain. They represent the evolution of Java toward expressiveness.

The key insight: Records automatically generate equals, hashCode, and toString — no more boilerplate for simple data classes.

Records Are Not Always a Drop-In Replacement

Section titled “Records Are Not Always a Drop-In Replacement”

Records cannot extend other classes (they implicitly extend java.lang.Record). If you need Inheritance, use a regular class. Records also cannot have mutable state — if your data carrier Needs to be mutated after construction, a record is the wrong tool.

// This does NOT compile
public record MutablePoint(double x, double y) {
public void setX(double newX) {
// No way to assign to the x field from an instance method
}
}

The permitted subclasses must be accessible to the sealed class at compile time. If they are in a Different package, they must be public. If they are in the same package but not public, they must Be at least package-private.

Pattern variables introduced by instanceof are effectively final. You cannot reassign them:

if (obj instanceof String s) {
s = "new value"; // Compile error: pattern variable s is effectively final
}

Arrow syntax in switch expressions does not fall through. If you use colon syntax (the traditional Style), fall-through still applies, and you must use break or yield explicitly:

// Arrow: no fall-through
switch (x) {
case 1 -> System.out.println("one");
case 2 -> System.out.println("two");
}
// Colon: fall-through still works (dangerous)
switch (x) {
case 1:
System.out.println("one");
case 2:
System.out.println("one or two"); // This executes for case 1 too!
}

Always prefer arrow syntax unless you deliberately need fall-through.

JEPFeatureJava VersionStatus
355Text Blocks (preview)13Preview
368Text Blocks (second preview)14Preview
378Text Blocks15Standard
305Pattern Matching for instanceof14Preview
375Pattern Matching for instanceof15Preview
394Pattern Matching for instanceof16Standard
325Switch Expressions (preview)12Preview
361Switch Expressions (second prev)13Preview
394Switch Expressions14Standard
409Sealed Classes17Standard
395Records16Standard
440Record Patterns (preview)19Preview
441Pattern Matching for switch21Standard
443Unnamed Patterns and Variables22Standard

This topic covers the core concepts of records, sealed classes, and pattern matching, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • OOP principles (encapsulation, inheritance, polymorphism)
  • collections framework
  • streams and lambda expressions
  • exception handling
  • the JVM and garbage collection

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.