Java’s Discriminated Union: Sealed Interfaces

Discriminated unions are a powerful data structure supported in many programming languages. They provide the ability to define a complex, defined data structure of types with exhaustive type checking and pattern matching. Simply put, a discriminated union limits an object’s structure to a defined set of possible types.

In Typescript, using a discriminated union would look something like this:


type Dog = {
    breed: "dog";
    furColor: string;
};

type Cat = {
    breed: "cat";
    eyeColor: string;
};

type Animal = Dog | Cat;

const foobar = (animal: Animal): string => {
    switch (animal.breed) {
        case "dog":
            return `It's a dog with ${animal.furColor} fur!`;
        case "cat":
            return `It's a cat with ${animal.eyeColor} eyes!`;
    }
};

Can we do this in Java?

For a long time, the answer to this question was no. Java did not support providing a defined set of type and performing exhaustive operations on an object. However, Java 17 now has its own way to emulate this behavior with the additions of sealed interfaces/classes and pattern matching.

Previously, a switch statement across an interface would look similar to this:


public interface Animal {
    String breed();
}

class Dog implements Animal {
    @Override
    public String breed() {
        return "dog";
    }

    public String bark() {
        return "woof";
    }
}

class Cat implements Animal {
    @Override
    public String breed() {
        return "cat";
    }

    public String meow() {
        return "meow";
    }
}

class AnimalFactory {
    public static String get(Animal animal) {
        return switch (animal.breed()) {
            case "dog" -> "It's a dog!";
            case "cat" -> "It's a cat!";
            default -> throw new IllegalStateException("Unexpected value: " + animal.breed());
        };
    }
}

The issue is the lack of strict typing and exhaustive checking around the cases of the switch statement. Without strict typing, the programmer must rely entirely on their knowledge of what values the field can be equal to. The compiler provides no support for what the possible values are for breed()and if any additional classes implement Animal. For instance, if I added a class Lizard with that returned lizard when calling breed(), the code would compile with no errors.

This may not seem like an issue for such a small use case. However, when working on a large codebase, making the change in every case where this switch statement occurs can be a lot. Additionally, to access any of the methods on the class implementations, we must cast the class ourselves.

Here’s how to do this in modern Java.

With modern Java, this switch statement becomes much easier to handle.


public sealed interface Animal permits Dog, Cat {
    String breed();
}

final class Dog implements Animal {
    @Override
    public String breed() {
        return "dog";
    }

    public String bark() {
        return "woof";
    }
}

final class Cat implements Animal {
    @Override
    public String breed() {
        return "cat";
    }

    public String meow() {
        return "meow";
    }
}

class AnimalFactory {
    public static String get(Animal animal) {
        return switch (animal) {
            case Dog dog -> String.format("Its a dog that barks %s", dog.bark());
            case Cat cat -> String.format("Its a cat that meows %s", cat.meow());
        };
    }
}

By turning Animal into a sealed interface that permits Dog and Cat, we are telling the compiler that the only classes allowed to implement Animal are Dog and Cat. This lets us perform a switch statement on the Animal object itself, with the cases being each of the interface implementations. Coupled with pattern matching, each case automatically and safely casts the object as its respective class implementation, allowing us to access the other methods in the class.

Additionally, there is no need for a default case. We’re switching on the type, and there is a defined set of possible types. That means no case will ever fall through to the default. If any new class becomes permitted as an implementation of the Animal interface, then the Java compiler will force a new case to be added for the new class.

Pattern Matching + Sealed Interface

Using pattern matching in conjunction with sealed interfaces, Java developers can leverage the power of switch statements to handle different cases based on the specific implementations of the sealed interface. This way, each case automatically performs a safe cast, enabling easy access to additional methods and properties specific to each implementation.

Modern Java combines sealed interfaces, pattern matching, and strict typing. That provides a more concise, maintainable, and predictable way to work with complex types and perform exhaustive operations. In turn, this enhances code readability, reduces the risk of errors, and facilitates better code maintenance and scalability.

As programming languages evolve, they’re adopting powerful language features like discriminated unions in TypeScript and sealed interfaces in Java. Those features enable developers to write more expressive and robust code, pushing the boundaries of what we can achieve in software development.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *