Skip to main content

Records, Sealed Classes & Pattern Matching

Modern Java versions (Java 16 through Java 21) introduced features designed to simplify data modeling, restrict class inheritance hierarchies, and clean up complex conditional logic using pattern matching.


1. Records (Java 16+)

A Record is a special kind of class in Java designed solely to act as a transparent carrier for immutable data.

Before records, writing a simple data-transfer object (DTO) required significant boilerplate code (fields, getters, constructors, equals(), hashCode(), and toString()). Records generate all of this code automatically.

Basic Record Syntax

public record UserDto(String username, String email, int age) {}

Under the hood, the compiler translates this declaration into a class where:

  • The class is declared final (cannot be extended).
  • The fields (username, email, age) are private and final.
  • A canonical constructor is created with all arguments.
  • Public getter methods are generated using the field names (e.g., username(), not getUsername()).
  • Proper implementations of toString(), equals(), and hashCode() are generated automatically.

Compact Constructors

You can customize validation or normalize data inside a record using a compact constructor (which has no parameters):

public record UserDto(String username, String email, int age) {
// Compact constructor
public UserDto {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
username = username.trim().toLowerCase(); // Normalize input
}
}

2. Sealed Classes and Interfaces (Java 17+)

A Sealed class or interface allows you to restrict which other classes or interfaces may extend or implement them. This provides structured, closed hierarchies that are useful for modeling domain structures.

Sealed Syntax

// Parent class declares it is sealed and lists permitted subclasses
public sealed class Vehicle permits Car, Truck, Motorcycle {}

// Permitted subclasses must declare their inheritance status:
public final class Car extends Vehicle {} // Option 1: final
public sealed class Truck extends Vehicle permits Semi {} // Option 2: sealed
public non-sealed class Motorcycle extends Vehicle {} // Option 3: non-sealed (opens inheritance)

Subclass Status Rules

Every class permitted by a sealed class must use exactly one of the following modifiers:

  1. final: The class cannot be extended further.
  2. sealed: The class can be extended, but only by permitted subclasses.
  3. non-sealed: The class can be extended by any class (removes sealing constraints).

3. Pattern Matching (Java 21+)

Pattern Matching simplifies checking types and extracting properties from objects. It works closely with sealed classes and records to eliminate casting boilerplate.

Pattern Matching with instanceof

// Traditional way
if (obj instanceof String) {
String s = (String) obj; // Manual casting
System.out.println(s.toUpperCase());
}

// Modern way (Pattern matching)
if (obj instanceof String s) { // Automatically binds 's' if true
System.out.println(s.toUpperCase());
}

Pattern Matching with switch

In Java 21, you can use pattern matching directly inside switch expressions. When paired with a sealed hierarchy, the compiler guarantees you have covered all possible branches, eliminating the need for a default case!


Practical Code Example: Shape Processor

This example combines sealed interfaces, records, and pattern matching inside a switch expression to compute and print shape attributes cleanly.

// 1. Define a sealed interface with permitted implementations
public sealed interface Shape permits Circle, Rectangle, Square {}

// 2. Define implementation classes as Records
public record Circle(double radius) implements Shape {}

public record Rectangle(double width, double height) implements Shape {}

public record Square(double side) implements Shape {}

// 3. Main processing class using pattern matching
public class ShapeProcessor {

// Switch expression returning double using pattern matching
public static double getArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Square s -> s.side() * s.side();
// No default case is needed! The compiler knows Shape is sealed
// and all permitted subclasses (Circle, Rectangle, Square) are covered.
};
}

public static void printShapeDetails(Shape shape) {
// Record pattern matching can directly unpack variables
switch (shape) {
case Circle(double r) ->
System.out.println("Circle with radius: " + r + " | Area: " + getArea(shape));
case Rectangle(double w, double h) ->
System.out.println("Rectangle: " + w + "x" + h + " | Area: " + getArea(shape));
case Square(double s) ->
System.out.println("Square with side: " + s + " | Area: " + getArea(shape));
}
}

public static void main(String[] args) {
Shape c = new Circle(5.0);
Shape r = new Rectangle(4.0, 6.0);
Shape s = new Square(3.0);

printShapeDetails(c);
printShapeDetails(r);
printShapeDetails(s);
}
}