Introduction
Following our first post about expressiveness and conciseness, this one zooms in on the single feature that does the most work toward making Scala terse and safe at once: pattern matching.
Today's example is one every backend engineer has hit: a call to a payment provider's API that comes back as one of a fixed set of outcomes (approved, declined with a reason, or a network-level failure), each needing its own branch. Miss a case, or treat "declined" the same as "failed", and the support queue will let you know.
Python's match: structural, but not enforced
Python 3.10 added structural pattern matching, a real step up from chains of if/elif. However, it is still flawed because it has no concept of exhaustiveness: nothing stops you from forgetting one or more cases, and, even worse, the compiler won't you when a new outcome type will be added, six months from now.
match result:
case Approved(confirmation_id=cid):
return charge_succeeded(cid)
case Declined(reason=reason):
return charge_declined(reason)
# Forgot NetworkFailure. Python won't tell you: it falls through
# silently unless you remember to add a catch-all and raise by hand.TypeScript: manual narrowing, manual diligence
TypeScript's discriminated unions are excellent for narrowing, but exhaustiveness isn't automatic. It's a pattern you have to know and apply yourself, via a default branch assigned to never. It works, but it's up to a developer's discipline to enforce that, not something the language can do for you: delete that line, or write a plain if chain instead of a switch, and the safety net is gone.
function handle(result: PaymentResult): string {
switch (result.kind) {
case "approved": return chargeSucceeded(result.confirmationId);
case "declined": return chargeDeclined(result.reason);
// Missing "networkFailure" case.
default: {
const _exhaustive: never = result; // now this fails to compile
throw new Error("unreachable"); // but only because we remembered to
} // write this exact boilerplate.
}
}Java's switch expressions: closer, still optional
Java 21 brought pattern matching for switch, and paired with a sealed interface it can get compiler-checked exhaustiveness. The catch is that it's opt-in at two separate points: you have to remember to seal the interface, and you have to actually write a pattern-matching switch (type patterns like case Approved a ->, not the old constant-label form) for the compiler to enforce it; that check applies to a plain switch statement just as much as to a switch expression, but most of the existing Java codebases were written with neither sealed interfaces, nor pattern-matching switches.
sealed interface PaymentResult permits Approved, Declined, NetworkFailure {}
// Exhaustive because PaymentResult is sealed and every case is a type
// pattern -- a switch statement here would be checked the same way.
String handle(PaymentResult result) {
return switch (result) {
case Approved a -> chargeSucceeded(a.confirmationId());
case Declined d -> chargeDeclined(d.reason());
case NetworkFailure f -> retryLater(f);
};
}Scala: exhaustiveness is the default
Scala's match is a first-class expression, and against a sealed hierarchy, exhaustiveness checking is simply how the compiler behaves.
sealed trait PaymentResult
final case class Approved(confirmationId: String) extends PaymentResult
final case class Declined(reason: String) extends PaymentResult
case object NetworkFailure extends PaymentResult
def handle(result: PaymentResult): String = result match {
case Approved(cid) => chargeSucceeded(cid)
case Declined(reason) => chargeDeclined(reason)
case NetworkFailure => retryLater()
}
// Comment out NetworkFailure ,and the compiler will warn you immediately:
// "match may not be exhaustive. It would fail on: NetworkFailure"That is the core difference from all three languages above. Scala does not treat exhaustiveness as a convention you remember to apply. The compiler already checked it, before you ran a single test.
And yes: out of the box, the compiler will just emit a warning; however, every production Scala build we've ever seen, diligently passes the -Werror (or Scala 2's -Xfatal-warnings) compiler flag, which turns every compiler warning into a build failure.
Beyond exhaustiveness: deep destructuring
Scala's match also destructures nested structures in a single pattern, where the other three languages need either nested conditionals, or several sequential narrowing steps. Matching on a Declined whose reason is specifically a card decline, versus any other kind of decline, reads as one pattern:
result match
case Declined(reason) if reason == "insufficient_funds" => promptForDifferentCard()
case Declined(otherReason) => logAndNotify(otherReason)
case Approved(cid) => chargeSucceeded(cid)
case NetworkFailure => retryLater()That guard clause (if reason == "insufficient_funds"), plus nested destructuring in a single case, would be two or three separate if statements in any of the other three languages. More branches to read, and more chances to get one of them slightly wrong.
The benefits for agentic coding
This is one of the clearest examples we know of for the AI angle. When an AI coding agent adds a new variant to a sum type (say, a Timeout case alongside NetworkFailure), every place in the codebase that pattern-matches on that type in Scala, gets flagged, by name, the moment the compiler runs: and with -Werror on (the norm, as explained above), that flag is a hard stop, not a suggestion the agent can build past.
In Python and unguarded TypeScript, the equivalent mistake (an agent adds a new outcome variant but misses updating one of the five places that switch on it) compiles and runs fine. It just handles the new case wrong, silently, and you find out from a support ticket instead of a build log. Java gets you most of the way there, but only if the codebase already used sealed interfaces and switch expressions, consistently, before the agent touched it. But if you're using a library/framework that predates those language features, then you're out of luck. Scala gives you that property by default, not as a style choice an agent has to already know to follow.
