Definition
- Algebraic data type (ADT) – a type formed by combining other types as a sum type (one of several alternatives) and a product type (a bundle of fields).
- Sum type – a sealed interface or class with a closed set of permitted subtypes models "exactly one of these variants."
- Product type – a
recordbundles several named components into one immutable value. - Exhaustive switch – over a sealed type, the compiler checks that every permitted variant is handled without a
default. - Illegal states unrepresentable – each variant carries only its relevant fields, preventing invalid field combinations.
Imagine National Bank of India describes a payment outcome as being exactly one of a fixed set of shapes: Success carrying an amount, Failure carrying a reason, or Pending carrying an expected date. Never two at once, and never an unknown fourth shape. A sealed type is the closed "one of these" list (the sum), and a record is how each shape bundles its own data (the product). A checklist with one row per shape is guaranteed complete, so no outcome is ever mishandled.
Understanding the Concepts
What is an algebraic data type?
An algebraic data type has two building blocks. A product type is a bundle of fields, like a struct. In Java a record is exactly this. A sum type says a value is exactly one of several alternatives. Combining sums of products is what "algebraic" means: the set of possible values is built by adding up alternatives, each of which multiplies together its fields.
package com.bank.model;
import java.math.BigDecimal;
// Sum type: one of these three alternatives
public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {
// Product type: bundles amount
record Success(BigDecimal amount) implements PaymentResult {}
// Product type: bundles reason
record Failure(String reason) implements PaymentResult {}
// Product type: bundles expectedDate
record Pending(String expectedDate) implements PaymentResult {}
}
How do sealed types deliver the sum?
A sealed interface with a permits clause declares that a value is exactly one of the listed variants. The set is closed, so no other variant can exist.
package com.bank.model;
import java.math.BigDecimal;
// Closed set: only these three outcomes exist
public sealed interface TransferOutcome {
record Completed(String txnId, BigDecimal amount) implements TransferOutcome {}
record Rejected(String reason) implements TransferOutcome {}
record Queued(String queueId, String expectedTime) implements TransferOutcome {}
}
How do records deliver the product?
Each record bundles its variant's own fields. Records are implicitly final, so they satisfy the sealed-subtype rule automatically. They are immutable and transparent, so the compiler knows their components.
// Each record is a product: bundled named components
record Completed(String txnId, BigDecimal amount)
implements TransferOutcome {
// Canonical constructor, accessors, equals, hashCode, toString
// all generated automatically
}
How does exhaustive switch consume the ADT?
A switch over the sealed type with a case per variant is checked by the compiler for completeness. No default is needed, and adding a new variant breaks every non-exhaustive switch.
package com.bank.service;
import com.bank.model.TransferOutcome;
import com.bank.model.TransferOutcome.*;
import java.math.BigDecimal;
public class TransferProcessor {
public String describeOutcome(TransferOutcome outcome) {
return switch (outcome) {
case Completed c -> "Transfer " + c.txnId()
+ " completed: " + c.amount() + " INR";
case Rejected r -> "Transfer rejected: " + r.reason();
case Queued q -> "Transfer queued (" + q.queueId()
+ "), expected at " + q.expectedTime();
// No default: compiler verifies exhaustiveness
};
}
public static void main(String[] args) {
TransferProcessor processor = new TransferProcessor();
System.out.println(processor.describeOutcome(
new Completed("TXN-5001", new BigDecimal("75000"))));
System.out.println(processor.describeOutcome(
new Rejected("Insufficient funds")));
System.out.println(processor.describeOutcome(
new Queued("Q-789", "14:30 IST")));
}
}
Transfer TXN-5001 completed: 75000 INR
Transfer rejected: Insufficient funds
Transfer queued (Q-789), expected at 14:30 IST
How do ADTs make illegal states unrepresentable?
With the old approach of one class with a status enum plus nullable fields, you could accidentally have a success that also had a failure reason set. With an ADT, a Success has no reason field and a Failure has no amount field. The invalid combination cannot be constructed.
package com.bank.model;
import java.math.BigDecimal;
// OLD approach: illegal states are representable
class OldPaymentResult {
String status; // "SUCCESS", "FAILURE", "PENDING"
BigDecimal amount; // null for failure?
String reason; // null for success?
// BUG: status="SUCCESS" but reason is set
}
// NEW approach: illegal states are impossible
// sealed interface PaymentResult
// permits Success(amount), Failure(reason), Pending(date)
// Success has no reason field. Failure has no amount field.
How do ADTs compose recursively?
Sealed-plus-records ADTs compose recursively for trees and expression grammars. This is the classic interpreter pattern done type-safely.
package com.bank.model;
import java.math.BigDecimal;
public sealed interface FeeExpr {
record Literal(BigDecimal value) implements FeeExpr {}
record Add(FeeExpr left, FeeExpr right) implements FeeExpr {}
record Multiply(FeeExpr left, FeeExpr right) implements FeeExpr {}
}
package com.bank.service;
import com.bank.model.FeeExpr;
import com.bank.model.FeeExpr.*;
import java.math.BigDecimal;
public class FeeEvaluator {
public BigDecimal evaluate(FeeExpr expr) {
return switch (expr) {
case Literal l -> l.value();
case Add a -> evaluate(a.left()).add(evaluate(a.right()));
case Multiply m -> evaluate(m.left()).multiply(evaluate(m.right()));
};
}
public static void main(String[] args) {
// fee = (500 + 200) * 1.18 (GST)
FeeExpr fee = new Multiply(
new Add(new Literal(new BigDecimal("500")),
new Literal(new BigDecimal("200"))),
new Literal(new BigDecimal("1.18"))
);
FeeEvaluator evaluator = new FeeEvaluator();
System.out.println("Fee with GST: " + evaluator.evaluate(fee) + " INR");
}
}
Fee with GST: 826.00 INR
How is this different from the old enum-plus-class approach?
| Feature | Enum + Class | Sealed + Records (ADT) |
|---|---|---|
| Variant-specific fields | Nullable, shared | Per-variant, non-nullable |
| Illegal states | Possible | Impossible |
| Exhaustive switch | Manual (default needed) | Compiler-checked |
| Pattern deconstruction | Not available | Record patterns |
| Immutability | Manual | Automatic (records) |
What about thread safety?
Records are immutable by default, making ADT values inherently thread-safe. You can pass a PaymentResult across threads without synchronization, and no thread can mutate it.
// Records are immutable: safe to share across threads
PaymentResult result = new PaymentResult.Success(new BigDecimal("75000"));
// Pass to any thread: no synchronization needed
What should a senior engineer know?
ADTs encourage total functions: a function that maps every variant to a result, verified exhaustive by the compiler. This is safer than scattered instanceof chains. In a bank, regulatory changes that add a new state are caught at compile time everywhere they must be handled. ADTs also pair naturally with immutable records for auditable, side-effect-free domain modelling.
Visual Overview
┌─────────────────────────────────────────────┐ │ Algebraic Data Type Structure │ ├─────────────────────────────────────────────┤ │ │ │ SUM TYPE (sealed interface) │ │ sealed interface PaymentResult │ │ │ │ │ ├── Success(amount) ← PRODUCT TYPE │ │ ├── Failure(reason) ← PRODUCT TYPE │ │ └── Pending(date) ← PRODUCT TYPE │ │ │ │ Consumer: exhaustive switch │ │ ┌──────────────────────────────────┐ │ │ │ case Success s → use s.amount() │ │ │ │ case Failure f → use f.reason() │ │ │ │ case Pending p → use p.date() │ │ │ │ // No default: compiler-checked │ │ │ └──────────────────────────────────┘ │ │ │ │ Recursive ADT (expression tree) │ │ sealed interface FeeExpr │ │ ├── Literal(value) │ │ ├── Add(left, right) │ │ └── Multiply(left, right) │ │ left, right are FeeExpr ↺ │ └─────────────────────────────────────────────┘
Key Takeaway
Algebraic data types are types built by combining sum types (one of several alternatives) and product types (bundles of fields). Java gives you product types through records and sum types through sealed types. Together with exhaustive pattern matching, they provide a complete, type-safe encoding of ADTs.
Sealed types deliver the sum. A sealed interface with a permits clause declares that a value is exactly one of a closed set of variants. Each variant is a record that bundles its own fields. In a banking project at National Bank of India, Pankaj models a payment outcome as a sealed type permitting Success (with an amount), Failure (with a reason), and Pending (with an expected date).
The big win is that ADTs make illegal states unrepresentable. With the old approach of one class holding a status enum plus nullable fields, you could have a success with a failure reason set. With the ADT, a Success has no reason field and a Failure has no amount field. The type system prevents the entire category of half-populated-object bugs.
Exhaustive pattern matching is the safe-consumption side. A switch with a case per variant is compiler-checked for completeness, and adding a new variant fails every non-exhaustive switch at compile time. The same pattern composes recursively: a fee-formula expression tree uses sealed Add, Multiply, and Literal variants with a total interpreter function.
Remember one sentence: sealed types provide the closed sum, records provide the product, and exhaustive switch consumes both, giving Java type-safe algebraic data types where illegal states are unrepresentable.
---