Skip to Content
Course content

139: Practice Exercise: Implementing a Simple State Machine with Sealed Classes

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers move from Java or C# to Kotlin and treat sealed classes as just "fancy enums." They aren't. The real power of a sealed class is how it interacts with the when expression to create a compile-time safety net. When you're building a state machine, that safety net is the only thing keeping your production environment from crashing when you add a new state six months from now.

The Silent Failure of the Else Branch

Take a look at this snippet. I'm simulating a simple payment gateway state machine. The developer here used a sealed class, which is a good start, but they made a classic mistake in the state handler.

sealed class PaymentState {
    object Pending : PaymentState()
    object Processing : PaymentState()
    object Completed : PaymentState()
    object Failed : PaymentState()
}

fun handlePayment(state: PaymentState) {
    when (state) {
        is PaymentState.Pending -> println("Waiting for funds...")
        is PaymentState.Processing -> println("Verifying transaction...")
        is PaymentState.Completed -> println("Payment successful!")
        else -> println("Payment is in an unresolved state.") 
    }
}

At first glance, this looks fine. It compiles, it runs, and it handles the Failed state via the else block. But here is where the bug hides: imagine your project grows, and you need to add a Refunded state. You add object Refunded : PaymentState() to the sealed class.

The code still compiles. The handlePayment function doesn't throw an error. But now, when a payment is refunded, it hits that else block and prints "Payment is in an unresolved state." Your business logic is now broken, but the compiler didn't warn you. You've essentially turned a powerful type-checking tool into a runtime guessing game.

Enforcing Exhaustiveness

The fix is simple but disciplined: never use else with sealed classes unless you truly have an infinite set of possibilities.

By removing the else block, you force the when expression to be exhaustive. If it's used as an expression (assigning to a variable or returning from a function), Kotlin will refuse to compile the code if a single state is missing. Even if used as a statement, most modern IDEs will highlight the when block in red if you've missed a branch.

sealed class PaymentState {
    object Pending : PaymentState()
    object Processing : PaymentState()
    object Completed : PaymentState()
    object Failed : PaymentState()
    object Refunded : PaymentState() // New state added
}

fun handlePayment(state: PaymentState) {
    // Removing 'else' makes this an exhaustive check
    when (state) {
        is PaymentState.Pending -> println("Waiting for funds...")
        is PaymentState.Processing -> println("Verifying transaction...")
        is PaymentState.Completed -> println("Payment successful!")
        is PaymentState.Failed -> println("Transaction failed. Please retry.")
        is PaymentState.Refunded -> println("Funds have been returned to the user.")
    }
}

Now, if you add a Cancelled state tomorrow, the compiler will point exactly to the handlePayment function and say, "Hey, you forgot to handle this." I personally find this far more comforting than hunting through logs to find out why a "Refunded" order is being treated as "unresolved."

Remember, sealed classes allow you to attach different data to different states—which we'll use in the exercise—but the real win is this architectural guardrail. You're letting the compiler manage your checklist of requirements.




📋 Practical Task

Exercise: Building a Document Approval Workflow State Machine

You need to implement a state machine for a document approval system. A document can be in one of the following states:

  • Draft: No extra data needed.
  • UnderReview: Needs to hold the name of the reviewer (String).
  • Published: Needs to hold the publishDate (String).
  • Rejected: Needs to hold the reason for rejection (String).

Requirements:

  1. Create a sealed class DocumentState with the four states listed above. Use data class for states that require parameters and data object (or object) for those that don't.
  2. Write a function processDocument(state: DocumentState) that uses a when expression to print a specific message for each state:
    • Draft: "Document is still being edited."
    • UnderReview: "Document is currently being reviewed by [reviewer]."
    • Published: "Document was published on [publishDate]."
    • Rejected: "Document was rejected due to: [reason]."
  3. Crucial: Ensure the when expression is exhaustive. Do not use an else branch.
  4. Test your implementation by creating a list of different DocumentState instances and passing them through the processDocument function.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.