Geschreven door Ian van Nieuwkoop

Haskell Did It First: Sealed classes and functional programming in Java

Development7 minuten leestijd

Java has come a long way since its early days as an object-oriented, imperative programming language. Over time, it has steadily adopted features inspired by functional programming, a paradigm focused on immutability, pure functions, and type safety—principles long embraced by languages like Haskell.

From lambda expressions and functional interfaces in Java 8 to records in Java 11, Java has been evolving to make functional programming more accessible. However, one crucial feature has been missing—the ability to define algebraic data types (ADTs) in a concise and type-safe way. With the introduction of sealed classes, Java now fills this gap, enabling developers to write code that mirrors the expressiveness and immutability of functional languages like Haskell.

In this blog, I’ll explore how Java is embracing functional programming principles and demonstrate why sealed classes are the missing piece of the puzzle for writing fully functional code in Java, using the purely functional language Haskel as an example.

What are sealed classes?

A sealed class in Java restricts which other classes or interfaces can extend or implement it, enabling developers to define a controlled hierarchy and enforce inheritance rules. This restriction is explicitly enforced by the Java compiler, ensuring that only specified, predefined subclasses are allowed to inherit from the sealed class. 

To declare a sealed class (or interface), you use the `sealed` keyword on the class definition, followed by a `permits` clause that lists all allowed subclasses:

public sealed class Shape permits Circle, Triangle, Rectangle {
    // class contents
}

In this example, `Shape` is a sealed class that is extended by `Circle`, `Triangle`, and `Rectangle`.

When a sealed class defines its permitted subclasses, those subclasses must be declared within the same module or package as the sealed class and compiled together. Each subclass of a sealed class must include one of the following modifiers. The final modifier indicates that the subclass is the last type in the hierarchy and cannot be extended further. The sealed modifier allows the subclass to define its own permitted subclasses, continuing the sealed hierarchy. The non-sealed modifier removes inheritance restrictions, allowing any class to extend this subclass.

public sealed class Shape permits Circle, Triangle, Rectangle {}

final class Circle extends Shape {}
sealed class Triangle extends Shape permits Pyramid {}
non-sealed class Rectangle extends Shape {} 
final class Pyramid extends Triangle {} 

// And an unknown, at runtime, number of children of Rectangle e.g.
final class Square extends Rectangle {} 

In this example, Shape is the sealed class that is extended by ‘Circle’, ‘Triangle’ and ‘Rectangle’. Circle is final and cannot be subclassed. Triangle is defined as sealed, and therefore must declare permitted subclasses. Rectangle is non-sealed meaning it can be subclassed, meaning it can be subclassed without restriction.

Recap Haskell

Haskell is a purely functional programming language that emphasizes immutability, lazy evaluation, and strong static typing. It supports higher-order functions, allowing functions to take other functions as arguments or return them—similar to lambda expressions and functional interfaces introduced in Java 8. Haskell also includes algebraic data types (ADTs), which enable expressive and type-safe data modeling. Its product types resemble records introduced in Java 11, while its sum types are similar to Java’s sealed classes introduced more recently.

Java has increasingly adopted features inspired by functional programming, particularly to address challenges in concurrency. Functional programming minimizes the use of variables and promotes immutability by default, ensuring computations are thread-safe. This makes functional programs highly scalable and reduces the risk of bugs caused by shared mutable state, offering significant advantages for modern, parallel systems.

Haskell List and Functional Java List

In the following sections, I’ll walk you through how a (simple) List is implemented in Haskell, followed by its functional equivalent in Java. I chose the List because it’s often considered the "Hello, World" of Haskell programming—simple, fundamental, and a great starting point for understanding functional concepts. 

-- Define a List data type 
data List a = Empty             -- Represents an empty list 
            | Cons a (List a)   -- Represents a non-empty list 
                                -- (head + tail) 

-- Add an element to the list 
addElement :: a -> List a -> List a 
addElement element list = Cons element list

In the first two lines, the List type is defined with two constructors: Empty, representing an empty list, and Cons (Constructor) , representing a value (the head) followed by the rest of the list (the tail). Notice the use of recursion. The letter ‘a’ is used in the same way as ‘T’ in Java to represent a generic type.

Next, a single global function called addElement is declared to add an element to the beginning of a list. This function is defined in two parts. The first line specifies its type signature—it takes a value of type a and a List of type a, then returns a new List of the same type. The second line provides the implementation, which is notably concise. It creates a new Cons entity, setting the provided value as the head and the existing list as the tail.

In the following code, a list is declared, and a new list is created by prepending an element. Notice that addElement returns a new list object, leaving the original list unchanged. This design ensures that the list is immutable by definition.

myList :: List Int
myList = Cons 1 (Cons 2 (Cons 3 Empty))       -- [1 2 3]

let newList = addElement 0 myList             -- [0 1 2 3]

Now let's write the same code in Java, in a pure functional way.

// Define the sealed class hierarchy
sealed interface List<T> permits Empty, Cons{} 

// Represents an empty listrecord 
Empty<T>() implements List<T> {}

// Represents a non-empty list (head + tail)record  
Cons<T>(T head, List<T> tail) implements List<T> {}

// Utility class to work with the List ADT
class ListUtils {
    // A useful to String method
    public static <T> String toString(List<T> list) {
        return switch (list) {
            case Empty<T> ignored -> "";
            case Cons<T> cons -> cons.head().toString() + " " 
               + toString(cons.tail());        
    };    
}    
    // Add an element to the list    
    public static <T> List<T> addElement(T element, List<T> list) {     
        return new Cons<T>(element, list);    
    }
}

By using a sealed interface List that only permits the records Empty and Cons, we can create an immutable algebraic data type (ADT) in Java. While some of Haskell’s compactness and conciseness is lost in translation, the result is still quite readable and maintains the functional programming style.

The global functions, toString and addElement, are defined in a utility class, although they could also be implemented directly within the List interface. Notably, the toString method uses pattern matching and ensures exhaustiveness in the switch statement, making the code both safe and expressive. This design maintains immutability and type safety, showcasing how Java can incorporate functional programming concepts inspired by languages like Haskell.

public class Main {
    public static void main(String[] args) {
        // Create a list: [ 1 2 3 ]
        var myList = new Cons<>(1, new Cons<>(2, 
                     new Cons<>(3, new Empty<>())));
        var newList = ListUtils.addElement(0, myList);

        // Will print: [ 0 1 2 3 ]
        System.out.println("[" + ListUtils.toString(newList) + "]"); 
    }
}

This code closely mirrors the previous Haskell example. It demonstrates how to initialize a List and create a new one by prepending a zero to the beginning. While Java already offers a variety of predefined thread-safe data structures, this example highlights how Java has taken further steps toward embracing functional programming.

Summary

In this blog, I demonstrated how Java has steadily adopted features from functional programming languages like Haskell. With the introduction of lambda expressions, records, and most recently, sealed classes, Java has evolved to support immutability, type safety, and expressive data modeling.

I also showed that Java can achieve many of the same benefits as Haskell—such as thread safety andscalability—while retaining its object-oriented foundation. With sealed classes filling the final gap, Java is now fully equipped to support a functional programming style, allowing developers to write cleaner, safer, and more maintainable code.