Sum types in Java
This article goes over what sum types are, how to create them in Java, and what kinds of problems they solve.
Table of Contents
Introduction
Unions, specifically tagged unions or more fitting to Java - Sum types, are a way to
define a type with various but predetermined existing types. Unlike a plain record with
components in the form: Type_1 AND Type_2 AND ... Type_n
, a sum type has the
form: Type_1 OR Type_2 OR ... OR Type_n
. This means you can have a type that
can be, for example, an IntResult
or a StringResult
but not both simultaneously.
The concept of sum types are typically built into pure or mostly functional languages, so creating them is usually very straightforward. Most object-oriented languages are highly nominal, so sum types require a bit more work than simply using the pipe operator
There are two major type systems: Nominal and Structural. In a nominal type system, If a method expects a type named
Cat
, it will only accept types namedCat
or types that can be cast toCat
.Structural type systems, on the other hand, focus more on a type’s structure. If a method expects a type named
Cat
, it will accept any type with the same structure asCat
. So, ifCat
has a string name, it will accept any type with a string name.It’s more nuanced than described above, but that is the general idea.
The problem
Assume you’re creating a buy/sell website. Your users can put up items for sale with the following constraints on an item:
- Can either be sold for a price OR
- Can be traded for a list of specified items OR
- Can be marked as “contact me”
How would you go about representing a data structure or a class that supports these constraints?
The solution without Sum types
It’s relatively easy to model this problem without sum types. Here’s a simple solution:
enum Price {SELL, TRADE, CONTACT_ME}
// An item is for sale if tradeOptions is null,
// An item is for trade if price is 0, and tradeOptions is not null,
// An item is for contact-me if price is 0 and trade options is null
record ItemPrice(Price priceType, double price, List<String> tradeOptions) {}
// In main method:
ItemPrice sale = new ItemPrice(Price.SELL, 100.0, null);
System.out.println(sale);
ItemPrice trade = new ItemPrice(Price.TRADE, 0, List.of("Phone", "Laptop"));
System.out.println(trade);
ItemPrice contactMe = new ItemPrice(Price.CONTACT_ME, 0.0, null);
System.out.println(contactMe);
You might even be able to come up with a better solution using Enums or generics. But, you’ll probably have the same issues as the solution above:
null
is a dangerous thing to willingly introduce in your code, especially since java doesn’t have a straightforward way of ensuring null safety.- The entire solution is a clunky and not very expressive. There’s too much mental gymnastics to understand what’s going on, and you’re one sleep-deprived night away from giving a value instead of a null.
The solution with Sum types
Sum types in java are created almost similar to other languages that are majorly object-oriented. Java 21 + is needed to run the given code:
// In a file named ItemPrice.java
import java.util.List;
sealed interface ItemPrice {}
record Sale(double price) implements ItemPrice {}
record Trade(List<String> tradeOptions) implements ItemPrice {}
record ContactMe() implements ItemPrice {}
// In main method:
ItemPrice sale = new Sale(100.0);
System.out.println(sale);
ItemPrice trade = new Trade(List.of("Phone", "Laptop"));
System.out.println(trade);
ItemPrice contactMe = new ContactMe();
System.out.println(contactMe);
The sealed keyword restricts what classes can implement the
ItemPrice
interface. By default, the interface is sealed to the file it’s defined in. This means that only classes in the same file can implement the interface.So, to ensure that no other class can implement
ItemPrice
, it should be defined in its own file. Or as an alternative, you can explicitly seal it with:sealed interface ItemPrice permits Sale, Trade, ContactMe
Not only is this solution much more expressive, it’s practically impossible to make a
mistake when creating an ItemPrice
object.
Further use case 1
Assume our API returns a JSON mapped to an ItemPrice
object. We don’t know
the specific type, but we need to handle all possible cases. For brevity, we’ll
print out their values:
// To simulate am unknown item price gotten from the user
// Note: If you print itemPriceFromWeb() multiple times, you should get different results
static ItemPrice itemPriceFromWeb() {
return switch ((int) (Math.random() * 3)) { // random number between 0 and 2
case 0 -> new Sale(100.0);
case 1 -> new Trade(List.of("Phone", "Laptop"));
default -> new ContactMe();
};
}
// In main method
switch (itemPriceFromWeb()) {
case Sale sale -> System.out.println("Sale price: " + sale.price());
case Trade trade -> System.out.println("Trade options: " + trade.tradeOptions());
case ContactMe ignored -> System.out.println("Contact me for price");
}
The print statement could easily be replaced with a method that does something more.
ignored
signals that we don’t need the value ofContactMe
. In Java 22+,_
can be used instead.
If you take out any case, the compiler throws an error because it already knows all
possible cases that can be returned from itemPriceFromWeb()
.
Further use case 2 + mini rant
This is more of a personal preference, and my big dislike for runtime exceptions.
If you have not used a method before, you have to dig into the source code to check if it throws a runtime exception. And even when you’ve used it multiple times, it’s still easy to forget to wrap your code in a try/catch.
Why is this a problem? Because you can’t handle what you don’t know is coming. Sure, you can write unit tests but, why not just handle it at compile time?
More verbose? Yes. More peace of mind? Also yes.
It makes no sense to rewrite libraries to use sum types, but I’m hoping new libraries would gradually ditch runtime exceptions and reserve them for cases where the program cannot continue.
For example, Integer.parseInt
throws a runtime exception if the string cannot be parsed.
Failing to parse a number is not a situation where the program cannot continue.
It simply means the method cannot continue. And an error should be returned.
Here’s a possible parseInt method. It has been kept simple for brevity:
In a file named Result.java:
public sealed interface Result {}
record Success(int num) implements Result {}
record Error(String errorMsg) implements Result {}
In a file named Integer.java:
public static Result parseInt(String toParse) {
if (toParse == null)
return new Error("Null string");
int result = 0;
for (int i = 0; i < toParse.length(); i++) {
char current = toParse.charAt(i);
if (!Character.isDigit(current)) {
return new Error("Non-digit character found");
}
// use bit shifting to convert char to int
result = (result << 3) + (result << 1) + (current - '0');
}
return new Success(result);
}
In the main method:
switch (Integer.parseInt("101232")) {
case Success s -> {
System.out.println("Parsed successfully and got: " + s.num());
// do more stuff here
}
case Error e -> {
// do whatever you want here. Exit, nothing, print the error message,
// etc.,
System.out.println(e.errorMsg());
}
}
With generics, the Result
interface could be made to work with any type.
Source Code
Download the source code: SumTypes.java
Execute with Java 21:
# Compile, run, and clean up
javac SumTypes.java && java SumTypes && rm *.class
Or with Java 22:
java SumTypes.java
References
- (YouTube) Algebraic Data Types + Pattern Matching = Elegant and readable Java code By Balkrishna Rawool
- https://en.wikipedia.org/wiki/Tagged_union
- https://openjdk.org/jeps/409
Read next: Using Java's foreign function API to Call C Libraries