ADTs (Algebraic Data Types) in Java
Introduction
Every Java developer has been there. We return a `List<Job>` from a search service, and suddenly we are not sure what an empty list means. Is there no data? Did the query fail? Was the query too broad?
We add a status field. Then a nullable `Integer estimatedCount`. Then someone adds another status code, and nobody updates the Javadoc.
Before long, we have a data structure where only some fields are meaningful depending on the value of some other field, and the compiler has nothing to say about it.
Fortunately, Algebraic Data Types, or ADTs, are the answer to this problem. They are a pattern for modeling data so that illegal states are not representable.
They come from functional programming languages like Haskell and Scala, but modern Java provides everything we need to use them directly through records and sealed interfaces.
In this article, we will look at what ADTs are, how to express them in Java 25, and why they make our domain models more correct and a lot easier to work with. All we need to follow along is Java 25 and a text editor. So, without further ado, let’s get started.
The Problem With the Old Approach
Let’s start with a concrete example. We are building a job search service.
The search can produce three different outcomes: it finds results, it finds nothing, or the query is so broad that we refuse to run it and return an estimate of how many results there would be.
The Nullable-Fields Trap
The classic Java approach is to return a result object with several fields:
1class JobSearchResult {2 private final String status; // "FOUND", "NO_MATCHES", "TOO_BROAD"3 private final List<Job> jobs; // only meaningful when status is "FOUND"4 private final Integer estimatedCount; // only meaningful when status is "TOO_BROAD"5
6 // constructor, getters...7}This works, but it has a lot of problems. The `jobs` field is null when the status is `"NO_MATCHES"`. The `estimatedCount` field is null when the status is `"FOUND"`.
The compiler does not know any of this. It will happily let us call `result.getJobs().size()` without checking the status first, and we get a `NullPointerException` at runtime (every Java developer’s old friend).
The invariant “if status is X, then field Y is populated” lives only in a comment. The compiler cannot enforce it.
The Open Hierarchy Trap
A second approach is to use a class hierarchy:
1class JobSearchResult {}2class FoundResult extends JobSearchResult { private final List<Job> jobs; / ... / }3class NoMatchesResult extends JobSearchResult {}4class QueryTooBroadResult extends JobSearchResult { private final int estimatedCount; / ... / }This is better because each subclass carries only the fields that make sense for it. However, the hierarchy is open.
Anyone can add a fourth subclass anywhere in the codebase. When we write a consumer, the compiler gives us no guarantee that we covered all the cases:
1String describe(JobSearchResult result) {2 if (result instanceof FoundResult found) {3 return "Found " + found.getJobs().size() + " jobs.";4 } else if (result instanceof NoMatchesResult) {5 return "No jobs matched your query.";6 }7 // We forgot QueryTooBroadResult. The compiler says nothing.8 return "";9}We need a way to tell the compiler: there are exactly three possible outcomes, and every consumer of a `JobSearchResult` must handle all three. Records and sealed interfaces give us exactly that.
Product Types: Records
As usual, we’ll start with the building blocks before we get to the full solution. The first one is the product type.
A product type is a type that bundles several fields into one value.
Why Not a Plain Class?
Before records, a Java developer would reach for a plain class to represent a simple value like a search query:
1class SearchQuery {2 private String keyword;3
4 public SearchQuery(String keyword) {5 this.keyword = keyword;6 }7
8 public String getKeyword() { return keyword; }9}This approach has several problems. The field is mutable by default. Marking it `final` prevents reassignment, but we must remember to do so. Nothing in the language enforces it.
We must also write `equals()`, `hashCode()`, and `toString()` by hand, or leave them incorrect. The class is verbose, and nothing prevents partial construction if we add more fields later.
So, are we doomed to write all this boilerplate forever?
What Records Give You
Java 16 introduced records as a first-class way to express product types. The same `SearchQuery` becomes:
1record SearchQuery(String keyword) {}A single record declaration gives us all of the following automatically:
- A constructor that sets all fields
- Immutable fields (declared `final` by the compiler)
- `equals()` and `hashCode()` based on all components
- `toString()` that includes every field
- Accessor methods named after the fields (e.g., `keyword()`, not `getKeyword()`)
We can model a job posting with the same approach. First, let’s define the small domain records we’ll build on for the rest of the article:
1record JobId(long value) {}2record Company(String name) {}3record Role(String title) {}4record Salary(double amount) {}Now the `Job` record is a product of all four:
1record Job(JobId id, Company company, Role role, Salary salary) {}In type algebra, we write it as:
Job = JobId × Company × Role × Salary
If `JobId` has N possible string values and `Company` has M possible string values, then `Job` has N × M × (values of `Role`) × (values of `Salary`) possible values. The total grows by multiplication. That is why we call this a product type.
Sum Types: Sealed Interfaces
The second building block is the sum type. Where a product type uses AND (a `Job` has an `id` AND a `company` AND a `role` AND a `salary`), a sum type uses OR.
Imagine we are modeling the outcome of looking up a single job by its id. There are exactly two outcomes: we either find the job, or we do not. A sum type expresses this:
1sealed interface JobLookupOutcome2 permits JobAvailable, JobMissing {}3
4record JobAvailable(Job job) implements JobLookupOutcome {}5record JobMissing(JobId id) implements JobLookupOutcome {}The `sealed` keyword is the key ingredient. It tells the compiler that `JobLookupOutcome` can only be one of the types listed in `permits`. No other class or interface outside the same package (or named module) may implement or extend it.
In type algebra:
1JobLookupOutcome = JobAvailable + JobMissingThe number of possible `JobLookupOutcome` values is the number of possible `JobAvailable` values PLUS the number of possible `JobMissing` values. That is where the name “sum type” comes from.
Sealed Hierarchies
Let’s look at the rules in detail. The `sealed` modifier closes a type hierarchy at compile time. Only the types in `permits` may implement the interface. Every permitted subtype must live in the same package or in the same-named module.
Each permitted subtype must also carry one of three modifiers. The `final` prevents further subclassing. `sealed` allows it via its own `permits` list. `non-sealed` reopens the hierarchy, removing the compiler guarantee for that branch.
1sealed interface JobAlert permits MatchAlert, SystemAlert, CustomAlert {}2
3// final: closed leaf — no class may extend MatchAlert4// Records are implicitly final, so the modifier is not written explicitly.5record MatchAlert(Job job) implements JobAlert {}6
7// sealed: controlled extension — only ExpiryAlert and QuotaAlert are permitted8sealed interface SystemAlert extends JobAlert9 permits ExpiryAlert, QuotaAlert {}10record ExpiryAlert(JobId id) implements SystemAlert {}11record QuotaAlert(int limit) implements SystemAlert {}12
13// non-sealed: the hierarchy is reopened — any class may extend CustomAlert14non-sealed abstract class CustomAlert implements JobAlert {15 abstract String message();16}`MatchAlert` is a `record`, so the `final` keyword is implicit. `SystemAlert` is itself `sealed`, adding a second controlled layer to the hierarchy. `CustomAlert` is `non-sealed`, which is an escape hatch: downstream code may extend it freely, but the compiler no longer guarantees exhaustiveness for that branch.
The `permits` clause is exhaustive in both directions. The compiler rejects a subtype that extends the sealed type but is not listed, and rejects a `permits` entry that does not extend the sealed type.
These rules give the compiler a complete, verified picture of every shape the sealed type can take. That picture is what makes exhaustiveness checking in switch expressions work.
Sealed Interfaces vs Enums
At this point, a reasonable question comes up. Java already has enums, and they also define a closed set of cases that the compiler can check exhaustively in a switch. So why reach for a sealed interface at all?
The difference is what each case is allowed to carry. An enum is a fixed set of singleton constants. Each constant is exactly one value, and every constant shares the same field layout:
1enum JobCategory { ENGINEERING, DESIGN, SALES }There is one `ENGINEERING` value, one `DESIGN` value, and one `SALES` value. We can attach fields through a constructor, but every constant has to fit the same shape. An enum is the right tool when the cases are plain labels with no data of their own.
A sealed interface is a fixed set of subtypes. Each variant is a real type that we instantiate freely, and each one carries its own fields with its own shape:
1sealed interface JobSearchOutcome2 permits Found, NoMatches, QueryTooBroad {}3
4record Found(List<Job> jobs) implements JobSearchOutcome {} // holds a list5record NoMatches() implements JobSearchOutcome {} // holds nothing6record QueryTooBroad(int estimatedCount) implements JobSearchOutcome {} // holds an int`Found` and `QueryTooBroad` have different shapes, and there is an unbounded number of `Found` values, one for every possible list of jobs. An enum fixes the set of values. A sealed interface fixes the set of types.
In the type algebra we already used, an enum is a sum of nullary cases:
1JobCategory = ENGINEERING + DESIGN + SALESEach term counts as exactly one value. A sealed interface, instead, is a sum of products, where each variant can be a product of several fields. In fact, an enum is the special case of a sealed sum type where every variant carries no data.
The rule of thumb is simple. If the cases are plain labels, use an enum. If the cases carry different data from one case to the next, reach for a sealed interface.
Hybrid Types: Sealed Interfaces with Records
Now we can put both building blocks together. Let’s model our original job search outcome properly:
1sealed interface JobSearchOutcome2 permits Found, NoMatches, QueryTooBroad {}3
4record Found(List<Job> jobs) implements JobSearchOutcome {}5record NoMatches() implements JobSearchOutcome {}6record QueryTooBroad(int estimatedCount) implements JobSearchOutcome {}This pattern is known in functional programming literature as a sum of products. The sealed interface is the sum (one of exactly three things). Each record is a product (a set of fields).
Each variant carries exactly the fields that make sense for it. The `Found` type has a list of jobs. `NoMatches` has no fields at all. The absence of results is the whole message.
`QueryTooBroad` has an estimate but no job list. There are no nullable fields. There is no “this field is only meaningful when the status is X” in a comment.
Let’s see the service that produces this type:
1class JobSearchService {2
3 private static final int MAX_RESULTS = 1_000;4
5 JobSearchOutcome search(SearchQuery query) {6 int estimated = repository.estimateMatches(query.keyword());7 if (estimated > MAX_RESULTS) {8 return new QueryTooBroad(estimated);9 }10 List<Job> results = repository.find(query.keyword());11 if (results.isEmpty()) {12 return new NoMatches();13 }14 return new Found(results);15 }16}So far, so good. The return type tells the whole story. A caller looking at the `search(...)` function immediately knows that exactly three things can come back, and each has a defined shape. No Javadoc needed to explain which fields are populated when.
Now we have a well-defined hybrid type. The next step is to consume it safely.
Pattern Matching on ADTs
Java gives us three tools for consuming ADTs: switch expressions, pattern matching, and record patterns. Each builds on the previous. Together, they let the compiler verify that every variant is handled.
Switch Expressions
A switch expression is a switch that returns a value, not just a control-flow statement.
The arrow syntax (`->`) eliminates fall-through and makes each branch return directly. The compiler also requires that all possible values are covered.
Why we used an `enum` here? We reach it on purpose. A switch expression works on any closed set of values, and an enum is the simplest one to reason about. We can also switch over a sealed interface, but picking out each variant relies on pattern matching for switch, a separate feature we get to next. Introducing them one at a time keeps it clear which Java version gives us what.
Here is a self-contained example using the `JobCategory` enum we saw earlier:
1enum JobCategory { ENGINEERING, DESIGN, SALES }2
3String label(JobCategory category) {4 return switch (category) {5 case ENGINEERING -> "Building the product.";6 case DESIGN -> "Shaping the experience.";7 case SALES -> "Closing the deals.";8 };9}If we remove one of the cases, the compiler refuses to compile:
1error: the switch expression does not cover all possible input valuesThe same exhaustiveness guarantee applies to sealed interfaces. Here is the `JobLookupOutcome` type from earlier, consumed with a switch expression:
1String status(JobLookupOutcome outcome) {2 return switch (outcome) {3 case JobAvailable available -> "The job is available.";4 case JobMissing missing -> "The job is no longer listed.";5 };6}There is no `default` branch, and none is needed. The `permits` clause already tells the compiler the list of variants is complete, so it checks exhaustiveness the same way it did for the enum. Each `case` names a variant type instead of a constant. That is pattern matching for switch, which we unpack in the next section.
Pattern Matching
Pattern matching for `instanceof` removes the need for explicit casts. We can test the type and bind a variable in a single step:
1Object obj = getResult();2if (obj instanceof String s) {3 System.out.println(s.length());4}The variable `s` is already of type `String` inside the block. No explicit cast is needed.
We can lift this into a switch expression to match on multiple types in a single construct:
1String format(Object value) {2 return switch (value) {3 case Integer i -> "Int: " + i;4 case String s -> "Str: " + s;5 default -> "Other";6 };7}Record Patterns
Record patterns go one step further. Inside a switch case, we can destructure a record inline. The compiler automatically extracts the record’s components, eliminating the need to call the accessor methods explicitly.
Here is the `JobLookupOutcome` switch written with record deconstruction:
1String describe(JobLookupOutcome outcome) {2 return switch (outcome) {3 case JobAvailable(Job job) -> "Found job: " + job.role().title();4 case JobMissing(JobId id) -> "No job found with id " + id.value();5 };6}The case `JobAvailable(Job job)` syntax matches a `JobAvailable` value and binds its `job` component to a local variable. The code is shorter and reads closer to the domain.
Record patterns also work with nested types. A `JobAvailable` holds a `Job`, which itself holds a `Role`. We can destructure both in a single case if needed.
The same tools work on hybrid types. Here is the consumer for `JobSearchOutcome`, the type we defined in the previous section:
1String describe(JobSearchOutcome outcome) {2 return switch (outcome) {3 case Found f -> "Found " + f.jobs().size() + " jobs.";4 case NoMatches n -> "No jobs matched your query.";5 case QueryTooBroad q -> "Your query is too broad (~" + q.estimatedCount() + " matches). Please refine it.";6 };7}Congratulations! We have just defined and consumed our first Algebraic Data Type in Java.
As we can see, the switch is an expression: it returns a value. The compiler checks that all three variants are handled. Inside each branch, we get a typed reference to the specific variant.
In the `Found f` branch, `f.jobs()` is available. In the `QueryTooBroad q` branch, `q.estimatedCount()` is available. Fields that do not apply to a variant are not reachable from that branch. That is the whole point.
Because the set of variants is closed, the compiler verifies exhaustiveness. If we forget a branch, the compiler refuses:
1error: the switch expression does not cover all possible input valuesThis is the safety net we were missing with the open class hierarchy. When we add a new variant, the compiler walks us to every place in the codebase that needs updating.
We stop relying on code review or tests to catch a forgotten case. The language enforces it for us.
The Pattern Generalizes: `Result<T, E>`
Once we see this shape, we start seeing it everywhere. A very common version of it is a generic success-or-failure type. We want to express that an operation either returned a value of type `T` or failed with an error of type `E`:
1sealed interface Result<T, E>2 permits Success, Failure {}3
4record Success<T, E>(T value) implements Result<T, E> {}5record Failure<T, E>(E error) implements Result<T, E> {}This is the same pattern as `JobSearchOutcome`, lifted to two type parameters. `Success` carries a `T`. `Failure` carries an `E`. To tell the truth, Java makes us pay a small tax here, and the following note explains exactly why.
Let’s define a concrete error type for job lookups as a sum type:
1sealed interface JobLookupError2 permits JobNotFound, DatabaseUnavailable {}3
4record JobNotFound(JobId id) implements JobLookupError {}5record DatabaseUnavailable(String reason) implements JobLookupError {}Let’s see a repository method that uses Result with this error type:
1class JobRepository {2
3 Result<Job, JobLookupError> findById(JobId id) {4 Job job = database.get(id);5 if (job == null) {6 return new Failure<>(new JobNotFound(id));7 }8 return new Success<>(job);9 }10}And the caller that consumes the result:
1String loadJobDescription(JobId id) {2 return switch (repository.findById(id)) {3 case Success<Job, JobLookupError>(Job job) ->4 "Role: " + job.role().title();5 case Failure<Job, JobLookupError>(JobLookupError error) ->6 switch (error) {7 case JobNotFound(JobId jobId) -> "No job found with id " + jobId.value();8 case DatabaseUnavailable(String reason) -> "Service unavailable: " + reason;9 };10 };11}Exhaustiveness still works. The compiler still refuses to let us forget a branch. The only new thing is generics; the pattern itself is identical.
Why “Algebraic”?
At this point, a fair question is: why are these types called “algebraic”?
An algebra is a set of objects and one or more operations to combine them into new objects of the same set. In numeric algebra, the objects are numbers and the operations are +, -, ×, and /.
For ADTs, the objects are the standard Java types, and the record and sealed-interface constructors are the operators. They let us build new types from existing ones.
We already used two operations:
We can define the complexity of a type as the number of values it can represent. Well-designed types have the least complexity; they need to model the information they carry.
Here is a concrete example. Suppose we need to model a set of mutually exclusive configuration options, `A`, `B`, or `C`. A naive product type would be:
1record Config(boolean a, boolean b, boolean c) {}This type has 2 × 2 × 2 = 8 possible values. In fact, only three of them are valid: `Config(true, false, false)`, `Config(false, true, false)`, and `Config(false, false, true)`.
The other five are invalid states that our code has to defend against.
A sum type solves this directly:
1sealed interface Config permits A, B, C {}2record A() implements Config {}3record B() implements Config {}4record C() implements Config {}This type has exactly three possible values. The five invalid states cannot be constructed. The compiler eliminates them at the type level. As we said, illegal states should not be representable at all.
We can apply the same thinking to our `Failure` type. Imagine we had defined the error as a raw `String` instead of a typed sum type:
1// Too broad: any string is a valid reason, whether it makes sense or not2record Failure<T, E>(String reason) implements Result<T, E> {}We already did better than this. We defined `JobLookupError` as a sealed interface with exactly two variants: `JobNotFound` and `DatabaseUnavailable`.
By using it as the type parameter `E`, we reduce the number of valid failure shapes to exactly two. There is no “what does this string mean?” question at runtime. The type itself tells us:
1Result<Job, JobLookupError> result = new Failure<>(new JobNotFound(id));We replaced an unbounded `String` with a sum type, and the set of valid failure values went from infinite to two. Less is better.
Conclusion
In this article, we introduced Algebraic Data Types in Java. We started with the pain of the traditional approaches: nullable fields that hide invalid states, and open class hierarchies that leave the compiler blind to missing cases.
We then established a shared domain vocabulary and introduced the two building blocks. We used records to express product types and sealed interfaces to express sum types.
We combined the two building blocks to create hybrid types, and we saw that the same pattern extends to the generic `Result<T, E>` type.
We covered switch expressions, pattern matching with instanceof and switch, and record patterns as tools for consuming ADTs. Record patterns let us destructure records inline, and the compiler enforces exhaustiveness over the closed set of variants.
We also explained what Java generics invariance means in practice and why both type parameters must appear on both Success and Failure.
We saw why these types are called “algebraic”: records are products (AND), sealed interfaces are sums (OR), and we can compose the two to model any domain.
We showed how replacing a raw String with a sum type removes invalid states from the design.
The pattern is the same one used in Haskell, Rust, and Scala under different names. In Java 25, we have all the tools to apply it directly. Illegal states should not be representable, and now they do not have to be.
Appendix: Maven Setup
The features used in this article (records, sealed interfaces, switch expressions, and pattern matching) are part of the Java standard library. No external dependencies are needed. The following pom.xml targets Java 25:
1<?xml version="1.0" encoding="UTF-8"?>2<project xmlns="http://maven.apache.org/POM/4.0.0"3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"4 xsi:schemaLocation="http://maven.apache.org/POM/4.0.05 http://maven.apache.org/xsd/maven-4.0.0.xsd">6 <modelVersion>4.0.0</modelVersion>7
8 <groupId>com.example</groupId>9 <artifactId>adt-java</artifactId>10 <version>1.0-SNAPSHOT</version>11
12 <properties>13 <maven.compiler.release>25</maven.compiler.release>14 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>15 </properties>16</project>Table of ContentsShare
Share on:
Twitter LinkedIn Reddit Facebook Pocket
This site uses cookies. Check our cookie policy (TLDR: no personal information is stored). For more information see our cookie policy.
Accept allReject all
%20in%20Java&fontFamily=Roboto&fontVariant=regular&layout=article&layoutIndex=1&configuration=og)

