Sealed Classes and Exhaustive Pattern Matching

java java21 scala kotlin sealed-classes pattern-matching interview-preparation

Sealed classes are a powerful feature for type-safe domain modeling. Java 17 introduced sealed classes and interfaces, bringing Java closer to Scala’s sealed traits and Kotlin’s sealed classes. In this post, we’ll explore how to model a payment system using sealed classes and implement fee calculation with exhaustive pattern matching.

The Problem: Type-Safe Payment Processing

Imagine we need to model a payment system that supports three payment methods:

  • Credit Card - 2.9% + $0.30 per transaction
  • Bank Transfer - flat $2.50 (under $1000) or $5.00 (over $1000)
  • Digital Wallet - 2.5% with $0.50 minimum

We want the compiler to:

  1. Restrict which types can represent a payment method
  2. Ensure we handle all payment types in our fee calculation
  3. Enable destructuring of payment data in pattern matching

Key Concepts

Sealed Classes/Interfaces

In all three languages, sealing a type restricts which other types can extend or implement it:

Language Keyword Behavior
Java sealed ... permits Must explicitly list permitted subtypes
Scala sealed Subtypes must be in same file
Kotlin sealed Subtypes must be in same package and module

Exhaustive Pattern Matching

When all possible subtypes are known at compile time, the compiler can verify that pattern matching covers all cases:

Language Construct No default needed
Java switch expression
Scala match expression
Kotlin when expression

Subtype Requirements

Each language has rules about what sealed subtypes must be:

Language Permitted Subtypes
Java Must be final, sealed, or non-sealed
Scala Case classes, objects, or other sealed traits
Kotlin Data classes, objects, or other sealed classes/interfaces

The Solution: Sealed Payment Types

// PaymentMethod.java - Sealed interface restricts implementations
public sealed interface PaymentMethod permits CreditCard, BankTransfer, DigitalWallet {
    BigDecimal amount();
}

// CreditCard.java - Record implementing sealed interface
public record CreditCard(String cardNumber, YearMonth expiryDate, BigDecimal amount)
        implements PaymentMethod {
    public CreditCard {
        // Compact constructor for validation
        Objects.requireNonNull(cardNumber, "Card number cannot be null");
        if (cardNumber.length() != 16 || !cardNumber.matches("\\d+")) {
            throw new IllegalArgumentException("Card number must be 16 digits");
        }
        // ... more validation
    }
}

// BankTransfer.java
public record BankTransfer(String iban, String bankCode, BigDecimal amount)
        implements PaymentMethod { /* validation */ }

// DigitalWallet.java
public record DigitalWallet(String provider, String accountId, BigDecimal amount)
        implements PaymentMethod { /* validation */ }

View full Java implementation

// PaymentMethod.scala - All subtypes must be in same file
sealed trait PaymentMethod:
  def amount: BigDecimal

case class CreditCard(cardNumber: String, expiryDate: YearMonth, amount: BigDecimal)
    extends PaymentMethod:
  require(cardNumber.length == 16 && cardNumber.forall(_.isDigit), "Card number must be 16 digits")
  require(!expiryDate.isBefore(YearMonth.now()), "Card has expired")
  require(amount > 0, "Amount must be positive")

case class BankTransfer(iban: String, bankCode: String, amount: BigDecimal)
    extends PaymentMethod:
  // validation with require()

case class DigitalWallet(provider: String, accountId: String, amount: BigDecimal)
    extends PaymentMethod:
  // validation with require()

View full Scala implementation

// PaymentMethod.kt - All implementations in same package/module
sealed interface PaymentMethod {
    val amount: BigDecimal
}

data class CreditCard(
    val cardNumber: String,
    val expiryDate: YearMonth,
    override val amount: BigDecimal,
) : PaymentMethod {
    init {
        require(cardNumber.length == 16 && cardNumber.all { it.isDigit() }) {
            "Card number must be 16 digits"
        }
        require(!expiryDate.isBefore(YearMonth.now())) { "Card has expired" }
        require(amount > BigDecimal.ZERO) { "Amount must be positive" }
    }
}

data class BankTransfer(val iban: String, val bankCode: String, override val amount: BigDecimal)
    : PaymentMethod { /* validation */ }

data class DigitalWallet(val provider: String, val accountId: String, override val amount: BigDecimal)
    : PaymentMethod { /* validation */ }

View full Kotlin implementation

Exhaustive Pattern Matching for Fee Calculation

The real power of sealed classes comes from exhaustive pattern matching. Let’s implement fee calculation in all three languages.

public static BigDecimal calculateFee(PaymentMethod payment) {
    // No default needed - compiler verifies all cases are covered
    return switch (payment) {
        case CreditCard(var cardNumber, var expiry, var amount) ->
            amount.multiply(new BigDecimal("0.029")).add(new BigDecimal("0.30"));
        case BankTransfer(var iban, var bankCode, var amount) ->
            amount.compareTo(new BigDecimal("1000")) < 0
                ? new BigDecimal("2.50")
                : new BigDecimal("5.00");
        case DigitalWallet(var provider, var accountId, var amount) ->
            amount.multiply(new BigDecimal("0.025")).max(new BigDecimal("0.50"));
    };
}
def calculateFee(payment: PaymentMethod): BigDecimal =
  payment match
    case CreditCard(_, _, amount)    =>
      amount * BigDecimal("0.029") + BigDecimal("0.30")
    case BankTransfer(_, _, amount)  =>
      if amount < BigDecimal("1000") then BigDecimal("2.50") else BigDecimal("5.00")
    case DigitalWallet(_, _, amount) =>
      (amount * BigDecimal("0.025")).max(BigDecimal("0.50"))
fun calculateFee(payment: PaymentMethod): BigDecimal =
    when (payment) {
        is CreditCard ->
            payment.amount * BigDecimal("0.029") + BigDecimal("0.30")
        is BankTransfer ->
            if (payment.amount < BigDecimal("1000")) BigDecimal("2.50") else BigDecimal("5.00")
        is DigitalWallet ->
            (payment.amount * BigDecimal("0.025")).max(BigDecimal("0.50"))
    }

Pattern Guards

public static String describeFee(PaymentMethod payment) {
    return switch (payment) {
        case CreditCard(var num, var exp, var amt) when amt.compareTo(new BigDecimal("100")) > 0
            -> "Credit card (high value): 2.9% + $0.30";
        case CreditCard(var num, var exp, var amt)
            -> "Credit card (standard): 2.9% + $0.30";
        case BankTransfer(var iban, var code, var amt) when amt.compareTo(new BigDecimal("1000")) >= 0
            -> "Bank transfer (high value): flat $5.00";
        case BankTransfer(var iban, var code, var amt)
            -> "Bank transfer (standard): flat $2.50";
        case DigitalWallet(var provider, var id, var amt)
            -> "Digital wallet (" + provider + "): 2.5% (min $0.50)";
    };
}
def describeFee(payment: PaymentMethod): String =
  payment match
    case CreditCard(num, _, amt) if amt > BigDecimal("100") =>
      s"Credit card (high value): 2.9% + $$0.30 on ${num.takeRight(4)}"
    case CreditCard(num, _, _) =>
      s"Credit card (standard): 2.9% + $$0.30 on ${num.takeRight(4)}"
    case BankTransfer(_, _, amt) if amt >= BigDecimal("1000") =>
      "Bank transfer (high value): flat $5.00"
    case BankTransfer(_, _, _) =>
      "Bank transfer (standard): flat $2.50"
    case DigitalWallet(provider, _, _) =>
      s"Digital wallet ($provider): 2.5% (min $$0.50)"
fun describeFee(payment: PaymentMethod): String =
    when (payment) {
        is CreditCard ->
            if (payment.amount > BigDecimal("100")) {
                "Credit card (high value): 2.9% + \$0.30"
            } else {
                "Credit card (standard): 2.9% + \$0.30"
            }
        is BankTransfer ->
            if (payment.amount >= BigDecimal("1000")) {
                "Bank transfer (high value): flat \$5.00"
            } else {
                "Bank transfer (standard): flat \$2.50"
            }
        is DigitalWallet ->
            "Digital wallet (${payment.provider}): 2.5% (min \$0.50)"
    }

Comparison Table

Feature Java 17+ Scala 3 Kotlin
Sealed declaration sealed interface/class ... permits sealed trait/class sealed interface/class
Must list subtypes Yes (permits) No (same file) No (same package/module)
Record/Data class record case class data class
Pattern matching switch expression match expression when expression
Destructuring Record patterns Case class patterns Smart casting
Guards when clause if clause Conditions in when
No default needed

When to Use Sealed Classes

Sealed classes are ideal for:

  1. Domain modeling - When you have a fixed set of variants (payment types, result types, states)
  2. State machines - Each state is a subtype with specific data
  3. Result types - Success/Error patterns with different payloads
  4. Command patterns - Different commands with type-safe handling

Best Practices

  1. Keep hierarchies small - Sealed classes work best with 3-7 subtypes
  2. Use records/data classes - Combine sealed with immutable data carriers
  3. Validate in constructors - Ensure invariants at construction time
  4. Leverage exhaustiveness - Don’t add default cases; let the compiler help you
  5. Document the hierarchy - Make the permitted subtypes clear

Interview Q&A: Sealed Classes in Practice

What is a sealed class in Java?
A sealed class limits which other types can extend or implement it. That gives you a strong boundary around a domain model. For example, if you model payment methods, you can say that only credit card, bank transfer, and wallet are valid subtypes. This is a very good fit for business rules.
Why is exhaustive pattern matching useful?
Because the compiler can check that all valid cases are handled. That reduces bugs where a new subtype is added but someone forgets to handle it. In a real system, this kind of safety is valuable because it catches missing business logic before runtime.
What is the difference between Java's sealed hierarchy and a normal interface?
A normal interface can be implemented anywhere, which means the system can grow in unexpected ways. A sealed hierarchy says: these are the only valid implementations. That makes the model tighter and gives the compiler more information for safe matching and validation.
How does this compare to Scala sealed traits?
It is very similar in purpose. Scala sealed traits also restrict the allowed subtypes and make exhaustive matching easier. Java's version is a bit more explicit because the compiler needs the allowed subclasses listed up front. The mental model is the same: use a closed set of domain events or business types.
When should I not use sealed classes?
They are a great fit for closed domain models, but not for every object in the system. If your hierarchy is open, or if third-party code needs to extend it, a normal abstract class or interface is better. Sealed types work best when the allowed cases are known and intentional.

Conclusion

Sealed classes bring type-safe ADT (Algebraic Data Type) patterns to Java, making it easier for Scala developers to apply familiar patterns. While Java’s syntax is more verbose than Scala’s, the concepts are nearly identical:

  • Both use sealing to restrict subtypes
  • Both enable exhaustive pattern matching
  • Both support destructuring in patterns
  • Both provide compile-time safety

The main difference is Java’s explicit permits clause versus Scala’s same-file requirement. Kotlin sits in between, requiring same package/module.

For Scala developers transitioning to Java, sealed classes combined with records provide the closest equivalent to sealed traits with case classes.

Code Samples

See the complete implementations in our repository:


This post is part of the Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: Functional Interfaces and Lambda Expressions and CompletableFuture and Asynchronous Programming.