Java Records, Sealed Types & Pattern Matching

Between Java 14 and Java 21, Java gained a set of features that transform how data is modeled. Records give concise, immutable data carriers with generated constructors, accessors, equals, hashCode, and toString. Sealed classes and interfaces declare closed hierarchies, meaning exactly these subtypes and no others. Pattern matching for instanceof and switch, plus record patterns, lets you test and deconstruct those types safely and exhaustively.

Together they enable data-oriented programming: modeling a domain as plain data with algebraic data types, then processing it with exhaustive switch expressions, the style of Kotlin sealed classes, Rust enums, and TypeScript discriminated unions (see type narrowing). The result is less boilerplate, and the compiler catches missing cases.

TL;DR

Quick Example

A payment domain as sealed records, processed with an exhaustive switch:

Core Concepts

Records

A record declares its state in its header:

Records are ideal for DTOs, value objects (domain-driven design), method return tuples, map keys, and events. JPA entities generally can't be records (they need mutability and no-arg constructors), but records work well as projections. See Spring Data JPA.

Sealed Classes and Interfaces

Permitted subclasses must be final, sealed, or non-sealed. Sealing documents the domain ("a payment result is one of exactly three things"), prevents unexpected subtypes, and enables exhaustiveness checking.

Pattern Matching for instanceof

It removes the cast-after-check boilerplate, and the binding is only in scope where the match is guaranteed.

Switch Expressions and Patterns

Modern switch:

Order matters: more specific cases (with guards) must come before general ones, and the compiler rejects dominated cases.

Data-Oriented Programming

The pattern: model data as immutable records, group alternatives into sealed interfaces (algebraic data types), keep operations in functions that switch over the data, and let the compiler enforce exhaustiveness. It complements object-oriented design. Use it when the set of variants is closed and operations vary, while classic polymorphism suits open hierarchies where new types are added often.

Best Practices

Validate in Compact Constructors

Make invalid records unrepresentable: check invariants and normalize values in the compact constructor, so every instance is valid. See error handling.

Copy Mutable Components

Use List.copyOf, Map.copyOf, or unmodifiable wrappers for collection components, to preserve true immutability.

Prefer Exhaustive Switches Over default

Omitting default in switches over sealed types means adding a new subtype produces compile errors at every switch that must handle it, which is far safer than silently falling into a default branch.

Use Records for Boundaries

API request and response DTOs, messages, events, and configuration properties are natural records: concise, immutable, and self-documenting. Jackson and Spring support them well.

Common Mistakes

Assuming Records Are Deeply Immutable

Adding default to Sealed Switches

A default branch defeats exhaustiveness checking: new subtypes silently fall into it. Omit it for sealed hierarchies, unless there's a genuine catch-all behavior.

Using Records for JPA Entities

Hibernate entities need mutable state, a no-arg constructor, and proxies, none of which records support. Use classes for entities and records for projections and DTOs.

FAQ

What's the difference between a record and a Lombok @Value class?

Both produce immutable data classes with accessors, equals, hashCode, and toString. Records are a language feature: no annotation processor, understood by the compiler for pattern matching and deconstruction, with a standard accessor naming style (name() rather than getName()). Records can't extend classes or add instance fields; Lombok offers more configuration.

Why use sealed interfaces?

To model closed sets of alternatives explicitly (payment results, commands, AST nodes, states), prevent unknown subtypes, and let the compiler verify that switches handle every case. They're Java's equivalent of algebraic data types.

What are record patterns?

Patterns that deconstruct a record into its components directly in instanceof or switch: case Point(int x, int y) -> …. They can nest (case Line(Point(var x1, var y1), Point p2) -> …), and combine with guards and unnamed patterns.

Which Java version do I need?

Records and sealed types are standard from Java 16 and 17, pattern matching for switch and record patterns are final in Java 21, and unnamed variables and patterns in Java 22. Java 21 LTS or later gives you the complete, stable feature set.

Related Topics

References