Core Java

Algebraic Data Types and Pattern Matching, Compared

Java, Rust, Scala, and Haskell all let you say “a Shape is exactly a Circle, a Square, or a Triangle, and nothing else.” They disagree sharply on what happens when you forget to handle one of them.

Picture a payment result that can only ever be Approved, Declined, or Pending. Nothing else is possible, by design. Every language in this comparison lets you encode that closed set of shapes directly in the type system, then forces (or at least invites) you to handle every case when you inspect one. That idea has a name, an algebraic data type, and four languages that take it seriously enough to build real tooling around it are Java, Rust, Scala, and Haskell. The concept is nearly identical across all four. The ergonomics, especially around what happens when you miss a case, diverge in ways that genuinely change how bugs surface in production.

The Same Idea, Four Syntaxes

An algebraic data type is just a type built from two operations: “and” (a product of fields, like a record) and “or” (a sum of alternatives, like an enum with payloads). A payment result is a sum type with three cases; a Circle with a radius is a product type. Combine sums and products and you can describe almost any shape of data a program needs.

Java: sealed interfaces plus records

Java builds the sum half from sealed interfaces, finalized in Java 17 via JEP 409, which let you declare exactly which classes are permitted to implement an interface, closing off the possibility of a fourth, unaccounted-for subtype. The product half comes from records, which bundle fields together with generated equals, hashCode, and accessors. A sealed interface with three record implementations is Java’s version of a three-case sum type, and it is unmistakably Java: verbose by comparison to the others, but instantly familiar to anyone who already knows interfaces and classes.

Rust: enums with payloads, natively

Rust doesn’t need to combine two features the way Java does. An enum in Rust is a sum type where each variant can carry its own payload directly, so Approved(f64), Declined(String), and Pending can all live as variants of one enum without any separate record-like construct. This is the most direct, first-class encoding of the four: the language’s basic enum keyword was designed from the start to be a full algebraic sum type, not repurposed from something older.

Scala: sealed traits and case classes

Scala’s approach looks the closest to Java’s on paper, a sealed trait playing the role of the sum, and case classes extending it playing the role of the product, complete with structural equality and pattern-friendly extractors generated automatically. Scala 3 also added a dedicated enum syntax that compiles down to the same sealed-trait-plus-case-class shape, trimming the boilerplate considerably. Scala got here first among these four, and Java’s sealed interfaces plus records are, by widespread acknowledgment, a direct descendant of this exact pattern.

Haskell: data declarations, the original

Haskell’s data keyword names the whole category: this is where the term “algebraic data type” comes from in the first place. One declaration, data PaymentResult = Approved Double | Declined String | Pending, defines every constructor and every field in a single line, with no separate interface-and-implementation ceremony at all. It is the most compact of the four syntaxes, precisely because Haskell never had an existing object model to retrofit the idea onto.

Where the Real Divergence Happens: Missing a Case

All four languages let you write a closed set of shapes. They disagree, sometimes surprisingly, about what happens the moment you pattern-match on one of these types and forget to cover every case.

LanguageDefault behavior on a missing caseCan you make it stricter?
RustHard compile error, E0004: non-exhaustive patterns. Code simply does not build.N/A, it’s already the strictest possible default
JavaHard compile error for pattern switches over sealed types, per JEP 441. Legacy switch statements are exempt and remain unchecked.N/A for pattern switches; legacy switches have no such option
ScalaCompiler warning only, by default. The program still compiles and will throw MatchError at runtime if the missing case is hit.Yes, via -Xfatal-warnings or the more targeted -Wconf:cat=other-match-analysis:error
Haskell (GHC)Nothing by default. -Wincomplete-patterns isn’t even enabled unless you ask for it.Yes, via -Wincomplete-patterns, and it can be escalated to an error with -Werror

That is a genuinely wide spread. Rust and Java’s pattern switches will simply refuse to compile broken code. Scala and Haskell will let it through silently unless a team has specifically opted into stricter settings, which means the exhaustiveness safety net that these languages are famous for is, in both cases, off by default.

Scala’s exhaustiveness checking has its own sharp edges

It’s worth being specific here, because Scala’s story is more nuanced than “warnings, not errors.” In Scala 2, sealed is famously non-transitive: sealing a trait does not seal its subtypes, so matching on a subtype directly, rather than on the sealed parent, silently drops exhaustiveness checking altogether. A documented Scala 3 compiler issue goes further still, showing a case class wrapping a sealed trait field where the compiler emits no warning at all for a missing case, even with -Xfatal-warnings turned on, only surfacing the gap as a runtime MatchError. These aren’t edge cases you’ll hit rarely; they’re exactly the kind of nested, realistic data shape production code actually uses.

Why Rust and Java chose to be strict by default

Rust’s match exhaustiveness has been a compile error since the feature existed; the Rust Book presents it as a core selling point of the language rather than an optional lint. Java’s JEP 441 made a comparably strong call, but scoped narrowly: exhaustiveness is only enforced for switches that use the new pattern-matching syntax over sealed types. An old-style switch statement over an int or a plain enum, the kind that has compiled unchanged since Java 5, still isn’t required to be exhaustive, so the safety only applies once you actually opt into the modern syntax.

How Developers Actually Feel About Each Language

Ergonomics eventually show up in sentiment data, and the pattern here lines up with the exhaustiveness story above in an interesting way.

Admired Score by Language, 2024

Share of developers who used the language this year and want to keep using it next year.Source: Stack Overflow 2024 Developer Survey, Technology section

Rust, the strictest and most first-class of the four ADT encodings, also carries the highest admiration score by a wide margin. That’s far from proof of causation on its own, plenty of other factors drive language sentiment, but it’s a notable data point that the language offering zero escape hatch from exhaustiveness checking is also the one developers are most eager to keep using.

Extensive Use Over the Past Year, 2024

Share of all survey respondents who reported extensive development work in the language.Source: Stack Overflow 2024 Developer Survey, Technology section

Usage tells the more expected story: Java’s enormous enterprise footprint dwarfs the other three, a reminder that adoption and ergonomics are separate questions, and that a language can be simultaneously the most widely used and the one whose exhaustiveness checking only applies to its newest syntax.

What This Means for Real Code

The practical stakes are concrete. Add a fourth case to a sum type, say a Refunded case alongside Approved, Declined, and Pending, and in Rust or in a Java pattern switch, every existing match immediately fails to compile until it’s updated. That is exactly the safety net exhaustiveness checking promises: the compiler finds every place your code needs to change, before anyone ships it. In Scala without -Xfatal-warnings, or in Haskell without -Wincomplete-patterns, that same change compiles cleanly, and the gap only becomes visible the first time a Refunded value actually reaches an old, un-updated match arm at runtime.

None of this makes Scala or Haskell’s ADTs weaker in concept. Their sum and product types are exactly as expressive as Rust’s or Java’s. It does mean the exhaustiveness guarantee that makes ADTs so trustworthy in principle has to be actively turned on in two of these four languages, and quietly verified to actually apply to the specific shape of data you’re matching against in all four.

Quick Checklist: Getting Real Exhaustiveness Guarantees From Each Language

  • In Rust, you already have it. Match exhaustiveness is a compile error with no flag required.
  • In Java, use pattern switches (switch with type patterns, JEP 441) over sealed interfaces, not legacy switch statements, which are never checked for exhaustiveness.
  • In Scala, add -Xfatal-warnings or the narrower -Wconf:cat=other-match-analysis:error, and double-check that you’re matching on the sealed parent type, not on an unsealed subtype.
  • In Haskell, turn on -Wincomplete-patterns explicitly (it’s part of -Wall), and consider -Werror in CI so it can’t be silently ignored.
  • In any of the four, re-test exhaustiveness after adding a new case to a shared sum type; the compiler should be the one telling you every place that needs updating.

What We Have Learned

Java’s sealed interfaces plus records, Rust’s enums, Scala’s sealed traits plus case classes, and Haskell’s data declarations all encode the exact same idea: a closed, finite set of shapes a value can take. The concept doesn’t diverge across these four languages. What diverges is the guarantee you get when you pattern-match on one and forget a case. Rust and Java’s pattern switches treat that as a compile error with no way to weaken it.

Scala and Haskell treat it as an opt-in warning, off by default, with Scala’s checking carrying additional gaps around non-transitive sealing and nested case-class fields that can silently swallow the warning even when it’s turned on. The lesson isn’t that any one of these languages got algebraic data types wrong; it’s that “the compiler will catch missing cases” is a claim worth verifying against each language’s actual defaults, not assuming from the presence of sealed types or a data keyword alone.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button