Introduction
In the last post, we used a PaymentResult sum type without dwelling on how it was built. We will fix this today by introducing the concept of algebraic data types (ADTs), and specifically the "sum type" half of them: a value that is exactly one of a fixed set of alternatives.
ADTs are not just a term from a type theory book. Design the types right and a whole category of bugs disappears, because the compiler will not let you build the illegal state in the first place. Let us model a payment status and see where that claim holds, and where it does not.
Python: no real sum type
Python has no first-class sum type. The idiomatic options are: string/enum tag plus loosely related optional fields ...
class PaymentStatus:
def __init__(self, status: str, decline_reason: str | None = None,
refund_amount: float | None = None):
self.status = status
self.decline_reason = decline_reason
self.refund_amount = refund_amount
# Nothing stops this: a "declined" payment carrying a refund amount,
# or an "approved" one with a decline_reason. Both are nonsense states
# that are perfectly constructible.
PaymentStatus(status="declined", refund_amount=49.99)... or a base class with subclasses that nothing forces you to treat exhaustively
class PaymentStatus:
pass
class Approved(PaymentStatus):
def __init__(self, confirmation_id: str):
self.confirmation_id = confirmation_id
class Declined(PaymentStatus):
def __init__(self, reason: str):
self.reason = reason
class Refunded(PaymentStatus):
def __init__(self, amount: float):
self.amount = amount
def handle(status: PaymentStatus) -> str:
if isinstance(status, Approved):
return charge_succeeded(status.confirmation_id)
elif isinstance(status, Declined):
return charge_declined(status.reason)
# Forgot Refunded. This runs fine and falls through silently: then what?Either way, the shape of a valid payment state lives in your head and your test suite, not in the type system.
TypeScript: discriminated unions, still imperfect
TypeScript's discriminated unions are a real improvement over Python's workarounds. Illegal combinations of fields become unrepresentable inside a single object literal, provided you write the union correctly. Keeping it correct is on the author, every time a variant is added. And a well-formed union says nothing about whether the places that consume it stay exhaustive. Nothing about PaymentStatus forces a switch over it to handle all three cases. That is the default: never trick from the last post, and it has to be applied by hand, on every single switch, or the compiler stays silent:
// Structurally safe: TS won't let you mix fields across variants in one literal
type PaymentStatus =
| { status: "approved"; confirmationId: string }
| { status: "declined"; reason: string }
| { status: "refunded"; amount: number };
function handle(status: PaymentStatus): void {
switch (status.status) {
case "approved":
chargeSucceeded(status.confirmationId);
return;
case "declined":
chargeDeclined(status.reason);
return;
// Forgot "refunded" entirely: this compiles, even in strict mode,
// but exhaustiveness is never checked.
}
}Java: sealed interfaces, a real tradeoff
Java 17's sealed interfaces plus records get close to Scala's model: a closed hierarchy the compiler knows about, with each variant carrying exactly its own fields.
sealed interface PaymentStatus permits Approved, Declined, Refunded {}
record Approved(String confirmationId) implements PaymentStatus {}
record Declined(String reason) implements PaymentStatus {}
record Refunded(double amount) implements PaymentStatus {}They are not more verbose than Scala's case classes either. record Approved(String confirmationId) implements PaymentStatus and final case class Approved(confirmationId: String) extends PaymentStatus are the same shape give or take a keyword, and both need one declaration per variant.
The real difference is where the variants have to live. Scala's sealed trait requires every direct subtype to sit in the same source file as the trait, no exceptions. Java's permits clause buys its way out of that: name every variant once, and the compiler checks the list in both directions. An implementer missing from the list fails to compile, and a name on the list that does not implement the interface fails too. In exchange, each variant can live in its own file. Skip permits and Java falls back to inferring the list, but only when every variant is declared in the same file as the interface.
That closed hierarchy pays off the moment something consumes PaymentStatus by using a pattern-matching switch:
void handle(PaymentStatus status) {
switch (status) {
case Approved a -> chargeSucceeded(a.confirmationId());
case Declined d -> chargeDeclined(d.reason());
// Forgot Refunded. This fails to compile:
// "the switch statement does not cover all possible input values"
}
}Scala: one declaration each, closed by construction
Everything shown for Java above, Scala gets without a minimum JDK version attached. sealed trait plus one case class per variant is the whole vocabulary, and it has been the idiomatic way to model data since Scala 2.
sealed trait PaymentStatus
object PaymentStatus:
final case class Approved(confirmationId: String) extends PaymentStatus
final case class Declined(reason: String) extends PaymentStatus
final case class Refunded(amount: Double) extends PaymentStatus
// Approved(status = "declined", refundAmount = 49.99) doesn't even compile,
// because there's no such constructor. A Declined value HAS a reason
// field and nothing else; there's no optional refundAmount to misuse.The variants aren't optional, though: sealed forces every one of them into the same file as the trait, so the compiler can enumerate the exact set it's checking a match against, the same mechanism behind the exhaustiveness checking covered in the pattern-matching post. Python has nothing like it; TypeScript only gets there if every consumer remembers the default: never trick by hand; Java needs both a recent-enough JDK and the newer pattern-matching switch syntax to catch up.
That closed set pays off the same way it did for Java above: pattern-match on PaymentStatus and leave a variant out, and the compiler catches it before the code ever runs and, with -Werror enabled, you'll get a nice compiler error in each place that needs your attention:
def handle(status: PaymentStatus): Unit = status match {
case Approved(cid) => chargeSucceeded(cid)
case Declined(reason) => chargeDeclined(reason)
}
// Forgot Refunded:
// "match may not be exhaustive. It would fail on pattern case: Refunded(_)"There's one final caveat though, and it runs the opposite direction from Java's story: a Java record is implicitly final, so it can't be subclassed or extended. A Scala case class carries no such guarantee. sealed only stops new direct subtypes of PaymentStatus from appearing outside this file; it says nothing about a subtype of Approved itself. Skip final on a case class and someone, somewhere, might extend it, and the result still matches case Approved(cid) => in every match, extra fields and all, because a match only ever tests the pattern it was given. The generated equals makes it worse, not better: it only compares the fields Approved itself declares, so two values can compare true while one is quietly carrying data the other never had. That is why we wrote "the variants aren't optional". You need both sealed and final. It is the principle of least privilege applied to data: describe the business data with exactly the information the job needs, then lock it. When it has to grow later, update the ADT and follow the compiler errors to every affected code path.
The real test: a richer domain
What we saw so far was just a contrived example: the real payoff compounds as the domain gets more realistic, fields grow in number, and even references other types within the same ADT.
Say a refund needs to track which approved payment it's refunding, and can't exceed the original amount at the type level in spirit (the actual bound still needs a runtime check, but the shape, the fact a refund always references an approval, is structural):
sealed trait PaymentStatus
object PaymentStatus:
final case class Approved(confirmationId: String, amount: Double) extends PaymentStatus
final case class Declined(reason: String) extends PaymentStatus
final case class Refunded(original: Approved, refundAmount: Double) extends PaymentStatus
// A Refunded value structurally CANNOT exist without an Approved
// payment attached to it. There is no code path that produces
// "refunded, but we don't know what was approved."Modeling that same invariant in Python, or in loosely-typed TypeScript, means writing a comment and then doing detective work to find every place that needs updating. Java is close to Scala here: add a record, update the permits clause, follow the compile errors. All four languages can express this. Scala and Java are the two where it is the path of least resistance rather than the disciplined path.
Illegal states and AI-written code
This one raises the reliability of AI-assisted work directly. An agent extending a Python class, or a loosely-typed TypeScript object, never gets told "this combination of fields doesn't make sense." It gets told nothing, because any combination is possible. It'll happily generate a function that builds a Declined payment with a refund_amount, because from where it's sitting, that's just a setting on a class instance.
Against a proper Scala ADT hierarchy, however, the same mistake doesn't compile. That's a correction the agent gets essentially for free, before the code is ever run (let alone reviewed), instead of a subtle production bug where a refunded payment's amount silently doesn't match anything real.
