Career Guide

Implementing Algebraic Data Types in Modern Java (Records + Sealed Classes)

Algebraic Data Types (ADTs) allow developers to model domain data with mathematical precision. Here is how records, sealed interfaces, and pattern matching achieve this in Java.

In functional programming, Algebraic Data Types (ADTs) are a powerful way to model domain data. They allow developers to define data structures with mathematical precision, ensuring that invalid states are impossible to represent. For years, Java developers had to rely on complex class hierarchies, design patterns, or verbose boilerplate to achieve similar safety. However, with the introduction of records (JDK 16) and sealed classes/interfaces (JDK 17), Java now has native support for ADTs. Combined with pattern matching for switch (JDK 21), modern Java provides a clean, type-safe way to implement and deconstruct ADTs.

Understanding Product and Sum Types

Algebraic Data Types are divided into two main categories:

  1. Product Types: A type whose set of possible values is the Cartesian product of other types. In simple terms, a type that has an "AND" relationship. For example, a User has a String name AND an int age. In modern Java, product types are perfectly modeled using Records. Records are immutable data classes that automatically generate constructors, getters, equals(), hashCode(), and toString().
  2. Sum Types: A type whose set of possible values is the sum of other types. In simple terms, a type that has an "OR" relationship. For example, a PaymentStatus can be Success OR Failed OR Pending. In modern Java, sum types are modeled using Sealed Classes or Interfaces. By sealing an interface, you restrict which classes or interfaces can implement it.

By combining sum types (sealed interfaces) and product types (records), you can build robust domain models.

Modeling a Checkout Flow Status

Let us model a realistic checkout flow status for an e-commerce application in India. A payment transaction can be in one of three states:

  • Pending: Currently processing.
  • Success: Completed successfully with a transaction ID and amount.
  • Failed: Rejected with an error code and a descriptive message.

Here is how we define this sum type using a sealed interface and records:

public sealed interface PaymentStatus permits Pending, Success, Failed {}

public record Pending() implements PaymentStatus {}

public record Success(String transactionId, double amount) implements PaymentStatus {}

public record Failed(String errorCode, String errorMessage) implements PaymentStatus {}

Notice how clean this model is. We have explicitly stated that PaymentStatus can only be implemented by Pending, Success, and Failed. No other class in the application can implement this interface. This immutability and restricted inheritance form the foundation of our ADT.

Deconstructing Records via Pattern Matching

Before JDK 21, processing these states would require verbose instanceof checks and explicit type casting. Now, we can use pattern matching in switch expressions to deconstruct our records inline.

Here is a code example showing how we can handle the checkout status:

public class CheckoutService {
    public String handlePayment(PaymentStatus status) {
        return switch (status) {
            case Pending() -> "Your payment is being processed. Please do not close the window.";
            case Success(String txId, double amt) -> String.format("Payment of ₹%.2f successful! Transaction ID: %s", amt, txId);
            case Failed(String errCode, String msg) -> String.format("Payment failed (Code: %s): %s. Please try again.", errCode, msg);
        };
    }
}

In the example above, the compiler automatically extracts the fields of the records (transactionId, amount, errorCode, and errorMessage) directly in the case label. There is no need for casting. We can use the variables immediately.

Compile-Time Exhaustiveness Checking

One of the greatest benefits of using sealed interfaces with switch expressions is compile-time exhaustiveness checking. Because the compiler knows that PaymentStatus has exactly three implementations (Pending, Success, and Failed), it verifies that the switch expression handles every single case.

If we omit one of the cases (for instance, if we forget to handle Failed), the compiler will throw a compilation error:

the switch expression does not cover all possible input values

This means you do not need a default clause. In fact, adding a default clause is discouraged because it defeats the safety. If you add a new payment status in the future (e.g., Refunded), the compiler will immediately flag every switch expression across your codebase that handles PaymentStatus, forcing you to implement the new case. This eliminates a whole class of runtime bugs and makes refactoring domain logic exceptionally safe.

📢 Share this article