Introduction
Algebraic data types are types that are often used by millions of developers every day. In fact as time goes on, more and more languages are adopting algebraic data types as a core feature. There's no better time than the present to learn about them.
A Soft Introduction to Type Theory
Type theory is simply a formal way of defining types. You can think of it to be similar to runtime analysis, but for types.
Notation
The next few sections will use a bit of set notation. Here's a quick refresher on the basics:
- means "is an element of". For example, holds.
- means "is a subset of". For example, holds.
- means "cardinality of" it counts the size of lenght of the set. For example, .
- means "cartesian product of". For example:
- means "is not an element of" it is the opposite of . For example, holds.
I first want to show you that a type can be expressed as a set of all it's possible values. For example, the type boolean
can be represented like so:
A char could be represented as (assuming it's a-z):
Again, because a char can only be one of the letters in the alphabet from a-z.
This representation is useful because it gives us a structured way to model types. For example, if we ask ourselves,
"is true
assignable to boolean
? We simply see if holds. On the contrary, if we ask ourselves,
"is 1
assignable to boolean
? We can see that holds. Neat!
In the next few sections I'll discuss some properties of these sets and how they are presented in Java1.
Sum Types
Things with algebraic data types get interesting when we start to apply operations to them. Let's start with a simple example.
Given an enum:
We can represent the type Color
as:
As Color
can only be RED
, GREEN
, or BLUE
. Note how it can't be two of these at the same time.
Let's count the number of possible values for Color
. We can do this by counting the number of elements in the set:
If we add another color to Color
, say YELLOW
, then the set of all possible values for Color
would be:
By now, you should see a very clear pattern, the number of possible types for Color
is the number of enum variants it has.
In other words, if we take the sum of the number of variants in an enum, we get the number of possible types for that enum. Because of this, enums are known as sum types.
In fact boolean
s are sum types too! Recall , and there are two variants for boolean
, true
and false
. therefore boolean
is a sum type.
Properties of Sum Types
Sum types exhibit certain properties that make them useful for developers. Let's go over them.
Exhaustiveness
Sum types are exhaustive, this both the developer and compiler able to make sure all variants are handled. For example, if we implement toString
for Color
like so using the new switch expression syntax2:
We can be sure that all variants of Color
are handled. If we add a new variant to Color
, say YELLOW
,
the compiler will complain that YELLOW
is not handled in the switch statement:
This makes our code much safer and forces us to handle all variants.
Uniqueness
Each constituent of a sum type is unique. For example, Colors.RED
is not Colors.GREEN
, and Colors.GREEN
is not Colors.BLUE
. Additionally, Colors
cannot take
on multiple variants at the same time. It cannot be Colors.RED
and Colors.GREEN
at the same time. Because of this we say that sum types are disjoint.
Product Types
So far we've only looked a types that take on one value at a time. Let's look at a type that can take on multiple values at the same time. Here's a more complex type, a tuple record with two fields:
Unlike the sum types we've seen so far, Container
can be multiple types at the same time. It can be foo=true, bar=true
, foo=true, bar=false
, foo=false, bar=true
, or foo=false, bar=false
.
What's the set of all possible types for Container
? Well, we can represent it as:
Notice something interesting? The set of all possible values for Container
is the cartesian product of the set of all possible values for boolean
with itself.
We could also represent Container
as a product of two boolean
s:
By this, we can assume that if another field boolean baz
was added to Container
(Containter(bool, bool, bool)
), the set of all possible values for Container
would be .
Container
is known as a product type for this specific reason, the set of types it represents is the product of the types it's composed of.
However, if we want to be more specific we can say that Container
is a product of sum types. This is because boolean
is a sum type, and Container
is a product of boolean
s.
Properties of Product Types
Composability
Product types are composable. This means that we can compose product types to create even more complex types.
If we create refer back to the Color
example, we can compose Color
with Container
to create a new type ColorContainer
:
We can represent the set of all possible values for ColorContainer
as:
Pretty complex set of possible values right? While this may be unneccearily complex, it's helps us understand even in the most complex cases, product types are still composable.
The problem with open types
An open3 type can be thought of as a type that can be extended or implemented by any data structure. For example, in Java, class can implement the Iterator<T>
interface.
Consider a Java interface defined like so:
We'll then add some variants:
I now have a new requirement I want to model Animal
s that can be house pets. So now I'll introduce a new interface:
I'll then make sure Dog
and Cat
implement HousePet
:
This is great! I can now use a constraint to handle cases specifically for HousePet
s:
I also need to handle specific tasks for each kind of HousePet
, so I do this:
I've just realized that I can't exhaustively handle all the cases for HousePet
since it's always open to be implemented. So instead I throw an error.
This works great! I know the only two cases that are HousePet
s are Dog
and Cat
, so I shouldn't have to worry about the exception being thrown, right?
A wrench thrown into the works
Well, a user of my library decides to implement HousePet
for their own Animal
variant:
When they call takeCareOfHousePets
they get an exception thrown. This is because I didn't handle the case for Snake
in my takeCareOfHousePets
method.
My code wasn't designed for people to add their own variants. However, due to the nature of access-control and interfaces, they can. Many jaded java developers would simply accept throwing an exception as a valid solution to this problem. However, exception throwing is a last resort. It's a hack to get around the fact that you can't exhaustively match on a product type. A more elegant solution could be validate this kind of error at compile-time.
This is where sum types come to the rescue.
Sum types to the rescue
In earlier versions of java the concept of a sum type was essentially using an enum with an associated value: We would remodel our code above to this:
Let's look at how we can handle this sum type:
Notice that we don't need a default
case in our switch statement. This is because we've exhaustively matched on all the variants of HousePet
. This is core benefit
of sum types. We can be sure that we've handled all the cases. The type cannot contain any extra variants beyond the ones we've defined.
Modern Java makes sum types easier
You may have noticed that the enum approach introduces an unwanted level of indirection. Modern versions of java introduce the concept of sealed types. This allows us to define our sum type like so:
This is a lot cleaner than the enum approach. We can now define our variants like we originally did:
Let's say our user decides to implement HousePet
for their own Animal
variant:
The type is now closed for extension and we get a nice compile-time error.
We can guarantee that we've handled all the cases for sealed classes like HousePet
by pattern matching instanceof
:
Other languages
The world of programming is made up of more than just java. Other languages have their own ways of handling sum types. Let's look at a few of them.
Rust
Rust takes a similar approach to java with the concept of enums and associated values:
Unlike Java, rust enum members can hold types that aren't covariant to other enum members. This means theoretically would could have a Person(Person)
variant in the HousePet
struct, even though a Person
isn't compatible to a HousePet
.
This behaviour allows rust enums to be more flexible than java enums in some instances.
Haskell
In terms of syntax, Haskell takes a different approach to sum types. It uses the |
(union) operator to define variants:
Similar to rust, each variant can be disjoint from the others.
Typescript
Typescript is similiar to haskell syntax-wise but uses types as-is rather than wrapping them in distinct named variants:
Because sum types in typescript don't have nominal labels, you can't match on them directly like you do in haskell or rust. Instead you need to use instanceof
checks
against the types:
However, this doesn't cover cases where objects are used instead of classes. We can't check instanceof
on objects. So we need to use a different approach. We can use
something called a "discriminant property" to identify the type of the object. This is a property that is unique to each variant. For example, we could add a type
to each variant:
Now we can disambiguate HousePet
objects by checking the type
property:
Conclusion
I should make it clear that polymorphic types are not useless by any means. Both sum types and polymorphic types are two different ways of modeling two different types of data.
For reference:
- When you have a type that is meant to be implemented, or is used to show a contract is satisfied. Use polymorphic types to model this. For example, if you want to model shapes, you can introduce an
interface with a
getArea()
method. This allows you introduce new shapes under theShape
type umbrella. - When you have a limited set of variants that may or may not be covariant with each other. Use sum types to model this. See our
HousePet
example above.
Footnotes
-
Some may scoff at my usage of Java to represent algebraic data types due to it's OOP nature. However, in recent updates Java supports algebraic data types through records and sealed classes. In addition, Java is arguably more readable than the alternatives such as Haskell or Rust, which makes it a good choice for learning purposes. ↩
-
This is not a formal term but I'm using it as a blanket term for classes, interfaces, etc. ↩