λ fbounded  :: Blog
All posts
#java #types #functional-programming #algebras

Algebraic Types: The Math Hiding in Your Java Code

Product types, sum types, and why combining them gives you a small, complete algebra, explained through Java records and sealed interfaces. First in a series leading to algebraic effects.

This article was revised. Read the original version

You Already Write Algebras

Most Java developers have written something like this:

record Point(double x, double y) {}

And when modelling a value that can be one of several distinct things, they reach for class inheritance:

class Colour {}
class Red    extends Colour {}
class Blue   extends Colour {}
class Yellow extends Colour {}
class Custom extends Colour {
    final int r, g, b;
    Custom(int r, int g, int b) { this.r = r; this.g = g; this.b = b; }
}

These two patterns (fields bundled together, and a fixed set of alternatives) represent two fundamental kinds of types that, when combined, give you something mathematicians call an algebra. That word sounds intimidating, but the idea is simple, and once you see it you’ll recognise the pattern everywhere.


Product Types: The AND

A record Point(double x, double y) holds an x and a y. Every Point has both; you can’t have one without the other.

This is called a product type because the number of possible values is the product of the possibilities of each field.

A boolean has 2 possible values. A record Pair(boolean a, boolean b) has 2 × 2 = 4:

  • (false, false)
  • (false, true)
  • (true, false)
  • (true, true)

The name comes from multiplication. Any class or record that bundles fields together is a product type:

record Person(String name, int age) {}        // String AND int
record Circle(Point center, double radius) {} // Point AND double

Java has always had product types. Every class with multiple fields is one. They’ve just never been called that.


Sum Types: The OR

A Colour is either Red, Blue, Yellow, or Custom. Never two at once, never something in between. This is a sum type (also called a tagged union or variant): a value that is exactly one of a fixed set of alternatives.

The name follows the same logic: if Red has 1 possible value, Blue has 1, Yellow has 1, and Custom(int r, int g, int b) has N possible values, then Colour has 1 + 1 + 1 + N possible values, hence “sum type”.

Class inheritance is what most Java developers reach for here. But it has a fundamental limitation: the hierarchy is open. Anyone can write class Purple extends Colour {} in another file. Because the compiler can’t know every possible subclass, it can’t verify that a switch covers all cases, leaving you on your own.

Functional languages have always had a first-class solution for this. In Scala, you’d write:

sealed trait Colour
case object Red    extends Colour
case object Blue   extends Colour
case object Yellow extends Colour
case class  Custom(r: Int, g: Int, b: Int) extends Colour

The sealed keyword locks the hierarchy to the current file. The compiler knows every possible Colour and can enforce exhaustive matching. Add a new variant and forget to handle it in a pattern match, and you get a compile error.

Java gained the same capability with sealed interfaces (introduced in Java 17, finalised in Java 21). The Scala definition above translates directly:

sealed interface Colour permits Red, Blue, Yellow, Custom {}
record Red()    implements Colour {}
record Blue()   implements Colour {}
record Yellow() implements Colour {}
record Custom(int r, int g, int b) implements Colour {}

Before sealed interfaces, Java developers also reached for enums as a limited substitute:

enum Direction { NORTH, SOUTH, EAST, WEST }

An enum with four variants has exactly four possible values, making it a sum type. Sealed interfaces extend the idea: each variant can carry its own data, and that data can differ between variants.


Putting Them Together: An Algebra

“Algebra” in everyday math means symbols and rules: x + y, 2 × 3. In type theory it means something slightly more general: a set of types together with the operations you can perform on them.

Sum types and product types are the vocabulary. Functions that consume or produce those types are the grammar.

Consider a tiny expression language:

sealed interface Expr permits Num, Add, Mul {}
record Num(int value)              implements Expr {}
record Add(Expr left, Expr right)  implements Expr {}
record Mul(Expr left, Expr right)  implements Expr {}

Add and Mul are both product types (they hold a left and a right sub-expression) and alternatives in the sum type Expr. You can build expressions by composing constructors:

// Represents: (2 + 3) * 4
Expr e = new Mul(
    new Add(new Num(2), new Num(3)),
    new Num(4)
);

Now add an evaluator:

static int eval(Expr expr) {
    return switch (expr) {
        case Num(var v)           -> v;
        case Add(var l, var r)    -> eval(l) + eval(r);
        case Mul(var l, var r)    -> eval(l) * eval(r);
    };
}

This is an algebra:

  • The type (Expr) is the set of values the algebra works over.
  • The constructors (Num, Add, Mul) are the ways to build those values.
  • The operation (eval) is something you can do with any value in the set.

The algebra is closed: every Expr can be evaluated, and every eval call returns an int. Nothing falls through. The compiler enforces completeness: the switch is exhaustive because Expr is sealed.


The Trade-off with Inheritance

Inheritance has one genuine advantage here: adding a new variant is easy. To add Neg to an inheritance-based expression hierarchy you write one class, and nothing else breaks:

class Neg extends Expr {
    Expr operand;
    Neg(Expr operand) { this.operand = operand; }
    int eval() { return -operand.eval(); }
}

With a sealed type, adding Neg means updating the permits clause, after which the compiler flags every switch that needs a new case. More up-front work, but the compiler does the bookkeeping instead of you.

The trade-off flips when you want to add a new operation. Say you need prettyPrint alongside eval. With inheritance, you either add the method to every class in the hierarchy or reach for the Visitor pattern. With an ADT, it’s one new function:

static String prettyPrint(Expr expr) {
    return switch (expr) {
        case Num(var v)        -> String.valueOf(v);
        case Add(var l, var r) -> "(" + prettyPrint(l) + " + " + prettyPrint(r) + ")";
        case Mul(var l, var r) -> "(" + prettyPrint(l) + " * " + prettyPrint(r) + ")";
    };
}

No existing code changes. No visitor, no dispatch table. The compiler checks every case is covered. That’s the practical payoff of sealed types: operations are cheap to add, and exhaustiveness is guaranteed.


Laws: What the Algebra Guarantees

Types and operations alone don’t make a useful algebra. The operations also need to satisfy laws: equations that must hold for any inputs.

For eval over integer addition, those laws are:

// Identity -- adding zero changes nothing
eval(new Add(new Num(0), e)) == eval(e)
eval(new Add(e, new Num(0))) == eval(e)

// Associativity -- grouping doesn't change the result
eval(new Add(new Add(a, b), c)) == eval(new Add(a, new Add(b, c)))

// Commutativity -- order doesn't matter
eval(new Add(a, b)) == eval(new Add(b, a))

These aren’t just nice properties. They’re what lets you safely simplify x + 0x, or reorder additions for parallel evaluation. The laws tell you exactly which rewrites are safe.

Laws also give you a concrete testing target. Instead of checking specific values, you can write property-based tests that assert a law holds for arbitrary inputs. If a refactoring breaks a law, a test catches it.

Not every algebra shares these laws. String concatenation is associative (("a"+"b")+"c" == "a"+("b"+"c")) but not commutative ("ab"+"c""c"+"ab"). A Result-chaining operation has identity laws but no commutativity. Different algebras, different guarantees. Knowing the laws tells you exactly what you can and can’t assume.


Why This Matters

Structuring your domain as an algebra gives you concrete, practical benefits.

Exhaustiveness checking. When you pattern-match on a sealed type, the compiler tells you if you’ve missed a case. Add a new variant and every switch that doesn’t handle it becomes a compile error, caught before you ship.

No invalid states. This is the central promise of sum types: make unrepresentable states unrepresentable. Consider what happens when you try to encode success and failure in a single flat record:

// Three fields, but most combinations are meaningless
record Result(String value, String error, boolean success) {}

The space of possible values is String × String × boolean, which is enormous. But only a tiny fraction are meaningful:

  • {"ok", null, true} → success ✓
  • {null, "something went wrong", false} → failure ✓
  • {null, null, true} → success with no value? ✗
  • {"ok", "also an error", true} → succeeded and failed? ✗
  • {null, null, false} → failure with no message? ✗

The type permits all of these. Your code has to guard against the invalid ones, or silently misbehave when it encounters them. This is exactly the kind of bug that slips through code review, because the type signature looks fine.

The mistake is treating a value and an error as fields that belong together. They don’t; they are alternatives. Model them that way:

// Impossible states are unrepresentable
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value)       implements Result<T> {}
record Err<T>(String error) implements Result<T> {}

Now the only representable states are an Ok with a value, or an Err with an error message. There is no flag to misread, no null to defend against, no undefined combination to stumble into. The type system makes an invalid Result structurally impossible to construct.

This is what sum types are for. Not just to label variants, but to shrink the space of representable values until it exactly matches the space of valid values.

Composability. Small algebras compose into larger ones. Expr uses int as its output. You could swap eval for a prettyPrint that returns String, or a typeCheck that returns a Type, each a completely different algebra, without changing Expr at all.


The Connection to Functional Programming

If you’ve encountered Haskell, Scala, or F#, this pattern will look familiar. Those languages call the combination of sum and product types algebraic data types (ADTs), and they’re central to how functional programs model their domains.

Java didn’t have good sum types until sealed classes landed in JDK 21. That’s one reason functional patterns felt awkward in Java for a long time. With sealed interface + record + exhaustive pattern matching, Java finally has the pieces, though the concepts were never part of the standard Java vocabulary.

The terminology (product, sum, algebra) comes from category theory, which is the branch of mathematics that underlies a lot of functional programming. You don’t need to know category theory to use these ideas. But knowing the names helps: you can search for patterns, recognise them in other languages, and reason about them more precisely.


Where This Is Going

Once you’re comfortable thinking in algebras (types as sets, constructors as vocabulary, functions as operations), a natural next question is: what about effects?

eval is pure: it takes an Expr and returns an int, nothing more. But real programs log things, read files, throw exceptions, run concurrently. How do you model those in an algebra? How do you keep the composability and exhaustiveness guarantees when your operations have side effects?

That’s the territory of algebraic effects, a model for structured, composable side effects that’s making its way into modern languages and runtimes. It’s the subject of the next post in this series.

For now: if you can write a sealed interface and a record, you can already think in algebras. You just didn’t have a name for it.

PS
Pradeep Samuel Tech Lead @ Nextiva