Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

How do records, sealed types and pattern matching change the way you model a domain?

Together they let you write closed data hierarchies the compiler can check. A record declares that a type is its components, a sealed interface enumerates the permitted variants, and an exhaustive switch over record patterns turns adding a variant into a compile error at every place that handles them.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate get the JDK versions right, including which features are still preview
  • Whether exhaustiveness is presented as the payoff rather than brevity
  • That they can say what a record does not give you, not only what it does
  • Whether they recognise when a sealed hierarchy is the wrong tool and polymorphism is the right one
  • Can they explain what a compact constructor is for

Answer

What landed when

Version accuracy is part of the answer here, because these features arrived across five releases and candidates routinely attribute all of them to JDK 21.

FeatureFinalised in
Pattern matching for instanceofJDK 16
RecordsJDK 16
Sealed classes and interfacesJDK 17
Pattern matching for switchJDK 21
Record patterns (deconstruction)JDK 21
Unnamed variables and patterns (_)JDK 22

Primitive types in patterns and instanceof remain a preview feature as of JDK 25, so it is not something to build on. JDK 21 is the release where the set became coherent, which is why it gets the credit, but records and sealed types were usable years earlier and saying so signals you have followed the releases rather than read one summary.

A record is a claim about identity

A record declares that the type is nothing but its components. You get a canonical constructor, accessors named after the components, and equals, hashCode and toString derived from every component. The important part is the semantic commitment: two records with equal components are the same value, and the language enforces that by generating the methods rather than trusting you to keep them in sync with the fields.

That commitment is also the constraint. A record cannot declare instance fields beyond its components, cannot extend another class, and its component fields are final. If your type has identity independent of its state — an entity with a database-assigned ID, something with a lifecycle, anything you mutate — a record is the wrong shape and forcing it produces worse code than a plain class.

Validation goes in a compact constructor, which is the piece most candidates have not used:

public record DateRange(java.time.LocalDate from, java.time.LocalDate to) {
    // Compact constructor: no parameter list, no field assignments. It runs
    // before the implicit assignment to the fields, so you validate here and
    // may also normalise by reassigning the parameters.
    public DateRange {
        if (to.isBefore(from)) {
            throw new IllegalArgumentException("to precedes from");
        }
    }
}

Sealed types make the compiler your reviewer

sealed lets a type enumerate its permitted implementations. On its own that is mildly useful documentation. Combined with pattern matching in switch it becomes an exhaustiveness check: a switch that covers every permitted subtype needs no default branch, and if someone adds a variant later, every such switch stops compiling until it is handled.

sealed interface Shape permits Circle, Rectangle, Triangle { }

record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

final class Areas {
    static double area(Shape shape) {
        // Record patterns bind the components directly; no accessor calls, no
        // casts. No default branch, so a fourth Shape breaks this line at
        // compile time rather than at run time.
        return switch (shape) {
            case Circle(double r) -> Math.PI * r * r;
            case Rectangle(double w, double h) -> w * h;
            case Triangle(double b, double h) -> b * h / 2;
        };
    }
}

That is the whole argument for the combination. Before it, an unhandled variant was a default branch throwing IllegalStateException — a run-time failure discovered by a user. Now it is a build failure discovered by the person making the change. The class of bug this removes is specifically "someone added a case and missed a place that handles cases", which in a codebase of any age is a frequent one.

How idiomatic modelling shifts

The practical change is that data and behaviour separate more often, and that is a deliberate reversal of the advice most Java developers absorbed.

Classical object orientation says: put area() on Shape as an abstract method, let each subtype implement it, and never switch on type. That is still right when the set of variants is open and the set of operations is small and stable — adding a new shape should not require editing existing files. A sealed hierarchy inverts the trade: the variants are fixed and known, and the operations are many and defined by the consumers. Once Shape is sealed, adding an operation is a new switch somewhere convenient, and adding a variant is a compile error everywhere it matters.

This is why sealed hierarchies fit result and command types so well. A parse result is Success | Failure and that will not grow. An event stream has a known set of event types, and each consumer cares about a different subset. Putting handleForBilling() and handleForAudit() as abstract methods on the event interface couples the event to every consumer; a sealed interface with switches in the consumers does not.

The choice between them is a real design decision with a name — variants versus operations — and being able to state it in those terms is what separates a senior answer from a recital of syntax.

Where the enthusiasm goes wrong

The mistake is treating a record as a guarantee of immutability. A record's fields are final; the objects they point to are not. A record holding a List hands every caller a reference to a list they can mutate, and the record's own hashCode changes with it, which breaks it as a map key. A record holding an array is worse: the generated equals compares that component with Object.equals, so two records with identical array contents are unequal.

If you want an immutable record you still do the work manually — copy mutable arguments in the compact constructor with List.copyOf, and return copies from accessors you override. The record gives you correct equality over the components you declared; it does not give you value semantics for components that do not have them.

Records and sealed types are not shorthand for the classes you were already writing. They let you tell the compiler that a hierarchy is closed and that a type is its data, and the return on that is a build failure instead of a production incident whenever the shape of the domain changes.

Likely follow-ups

  • Why can a record not have instance fields beyond its components?
  • When is a sealed hierarchy plus a switch worse than an abstract method on the interface?
  • What happens to an exhaustive switch when the sealed interface gains a permitted subtype and you do not recompile the consumer?
  • Why do records make awkward JPA entities?

Related questions

Further reading

recordssealed-typespattern-matchingjdk-21idiomatic-java