Java tagged union / sum types

3 min read 06-10-2024
Java tagged union / sum types


Java's Missing Sum Types: A Look at Tagged Unions and Alternatives

The Problem: Modeling Different Data Structures

In software development, we often encounter situations where we need to represent data that can take on different forms. For example, consider a payment system where the amount can be paid via credit card, bank transfer, or cash. How do we model this in our code?

A naive approach might be to use a single class with multiple fields, each representing a possible payment method. However, this leads to code that is verbose, error-prone, and difficult to maintain. The issue is that we need a way to model disjoint data structures, meaning that an instance can only belong to one type at a time.

This is where the concept of tagged unions or sum types comes into play. They allow us to represent data that can be one of several distinct possibilities, clearly distinguishing them from each other.

Java's Conventional Approach: The "Null" Problem

Java, traditionally, doesn't offer a native construct for sum types. Developers often resort to using null values within a single class to represent different payment methods. This leads to the infamous "null pointer exception" and makes it difficult to reason about the code's behavior.

Let's consider a simplified example:

class Payment {
    String creditCardNumber;
    String bankAccountNumber;
    String cashAmount;

    // ... other methods ...
}

// Usage:
Payment payment = new Payment();
payment.creditCardNumber = "1234567890123456"; // Credit card payment
// Or:
payment.bankAccountNumber = "4567890123456789"; // Bank transfer payment
// Or:
payment.cashAmount = "50"; // Cash payment

// ... later in the code, you need to handle the payment type
if (payment.creditCardNumber != null) {
    // Handle credit card payment
} else if (payment.bankAccountNumber != null) {
    // Handle bank transfer payment
} else if (payment.cashAmount != null) {
    // Handle cash payment
} else {
    // Handle invalid payment
}

This approach is error-prone, as it relies on checking null values to determine the payment type. A single mistake, like forgetting to set any of the fields or setting multiple fields, can lead to unexpected behavior and crashes.

Alternatives to Java's Built-in Sum Types

While Java lacks a dedicated sum type feature, there are various workarounds and external libraries to implement this pattern:

  • Sealed Classes (Java 15+): Introduced in Java 15, sealed classes allow for a limited form of sum types. They restrict subclasses to a fixed set, ensuring that only the defined types can be used. However, they still rely on inheritance and don't offer the elegance of a dedicated sum type construct.

  • Enums with Data: Enums can be used to represent different payment methods, but they lack the flexibility of holding additional data associated with each method.

  • The "Builder" Pattern: This pattern allows you to create different objects representing each payment type. It's more verbose, but it provides better type safety and clarity.

  • External Libraries: Libraries like vavr, Immutables, and Lombok offer specialized constructs for sum types. These libraries provide more convenient ways to work with them, but they add additional dependencies to your project.

Example: Using vavr for Sum Types

import io.vavr.control.Either;

class Payment {
    private Either<CreditCard, BankTransfer> paymentMethod;

    public Payment(CreditCard creditCard) {
        this.paymentMethod = Either.left(creditCard);
    }

    public Payment(BankTransfer bankTransfer) {
        this.paymentMethod = Either.right(bankTransfer);
    }

    // ... other methods ...
}

class CreditCard {
    String number;
    // ... other fields ...
}

class BankTransfer {
    String accountNumber;
    // ... other fields ...
}

// Usage:
Payment creditCardPayment = new Payment(new CreditCard("1234567890123456"));
Payment bankTransferPayment = new Payment(new BankTransfer("4567890123456789"));

// ... later in the code, you can easily access the payment type and data:
if (creditCardPayment.paymentMethod.isLeft()) {
    // Access credit card data
} else if (creditCardPayment.paymentMethod.isRight()) {
    // Access bank transfer data
}

This example demonstrates how vavr's Either type can be used to represent a payment method as either a credit card or a bank transfer. The isLeft() and isRight() methods provide clear and type-safe access to the appropriate data.

Conclusion

While Java lacks built-in support for sum types, using external libraries or designing patterns like sealed classes or the builder pattern can help developers work with data structures that require distinct and mutually exclusive representations.

The use of sum types improves code clarity, reduces errors, and makes it easier to maintain and reason about the code's behavior. As more libraries and language features embrace this pattern, we can expect to see its adoption become even more widespread in Java development.

Resources