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:
- 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
Userhas aString nameAND anint age. In modern Java, product types are perfectly modeled using Records. Records are immutable data classes that automatically generate constructors, getters,equals(),hashCode(), andtoString(). - 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
PaymentStatuscan beSuccessORFailedORPending. 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.