How do I use sealed classes for exhaustive type-safe hierarchies in Kotlin?

In Kotlin, sealed classes (and sealed interfaces) let you model a closed, type-safe hierarchy: a fixed set of known subtypes. This is especially useful for things like UI state, results, commands, events, and domain-specific alternatives.

Basic idea

A sealed class restricts which classes can inherit from it.

sealed class Result

data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
data object Loading : Result()

Now Result can only be one of the known subclasses: Success, Error, or Loading.

Exhaustive when

The main benefit is that Kotlin can check whether a when expression handles every possible subtype.

fun render(result: Result): String {
    return when (result) {
        is Success -> "Data: ${result.data}"
        is Error -> "Error: ${result.message}"
        Loading -> "Loading..."
    }
}

Because Result is sealed, the compiler knows all possible cases. You do not need an else branch if all cases are covered.

If you add another subtype:

data object Empty : Result()

Then this when becomes incomplete, and the compiler will require you to handle Empty.

Prefer data object for singleton states

For sealed hierarchies with singleton cases, use data object:

sealed class UiState {
    data object Loading : UiState()
    data object Empty : UiState()
    data class Success(val items: List<String>) : UiState()
    data class Error(val cause: Throwable) : UiState()
}

Usage:

fun message(state: UiState): String =
    when (state) {
        UiState.Loading -> "Loading"
        UiState.Empty -> "No items"
        is UiState.Success -> "Loaded ${state.items.size} items"
        is UiState.Error -> "Failed: ${state.cause.message}"
    }

Sealed classes vs enums

Use an enum class when every case is a simple constant:

enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

Use a sealed class when cases may carry different data:

sealed class PaymentStatus {
    data object Pending : PaymentStatus()
    data class Paid(val receiptId: String) : PaymentStatus()
    data class Failed(val reason: String) : PaymentStatus()
}

Sealed interfaces

A sealed interface is useful when subclasses may also extend another class, or when you want multiple sealed abstractions.

sealed interface NetworkState

data object Offline : NetworkState
data object Connecting : NetworkState
data class Online(val bandwidthMbps: Int) : NetworkState

Usage:

fun describe(state: NetworkState): String =
    when (state) {
        Offline -> "Offline"
        Connecting -> "Connecting"
        is Online -> "Online at ${state.bandwidthMbps} Mbps"
    }

Nesting subclasses inside the sealed type

A common style is to define all cases inside the sealed class for readability:

sealed class AuthResult {
    data class Success(val userId: String) : AuthResult()
    data object InvalidCredentials : AuthResult()
    data object NetworkFailure : AuthResult()
}

Then use it like this:

fun handle(result: AuthResult): String =
    when (result) {
        is AuthResult.Success -> "Welcome ${result.userId}"
        AuthResult.InvalidCredentials -> "Invalid username or password"
        AuthResult.NetworkFailure -> "Please check your connection"
    }

Generic sealed result type

A common pattern is a generic result wrapper:

sealed class AppResult<out T> {
    data class Success<T>(val value: T) : AppResult<T>()
    data class Failure(val error: Throwable) : AppResult<Nothing>()
    data object Loading : AppResult<Nothing>()
}

Example usage:

fun display(result: AppResult<String>): String =
    when (result) {
        is AppResult.Success -> "Value: ${result.value}"
        is AppResult.Failure -> "Error: ${result.error.message}"
        AppResult.Loading -> "Loading..."
    }

The out T makes AppResult covariant, so AppResult<String> can be used where AppResult<Any> is expected.

Rules to remember

In modern Kotlin:

  • Direct subclasses of a sealed class/interface must be in the same package.
  • They must be declared in the same module.
  • Sealed subclasses can be top-level or nested.
  • A sealed class is abstract by default.
  • Sealed classes cannot be instantiated directly.

Example:

sealed class Command

data class CreateUser(val name: String) : Command()
data class DeleteUser(val id: Long) : Command()
data object Sync : Command()

Exhaustive when as an expression

For exhaustiveness checking, prefer using when as an expression:

val text = when (state) {
    UiState.Loading -> "Loading"
    UiState.Empty -> "Empty"
    is UiState.Success -> "Success"
    is UiState.Error -> "Error"
}

If you use when only as a statement, exhaustiveness checking may be less useful depending on context and Kotlin version/settings.

Practical example: UI state

sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Loaded(
        val name: String,
        val email: String
    ) : ProfileUiState
    data class Failed(val message: String) : ProfileUiState
}

fun renderProfile(state: ProfileUiState): String =
    when (state) {
        ProfileUiState.Loading -> "Loading profile..."
        is ProfileUiState.Loaded -> """
            Name: ${state.name}
            Email: ${state.email}
        """.trimIndent()
        is ProfileUiState.Failed -> "Could not load profile: ${state.message}"
    }

Best practices

  • Use sealed types to represent a closed set of alternatives.
  • Use data class for cases with data.
  • Use data object for singleton cases.
  • Avoid unnecessary else branches in when; let the compiler check exhaustiveness.
  • Keep sealed hierarchies small and meaningful.
  • Prefer sealed interfaces when you need more flexible inheritance.
  • Prefer enums for simple constant-only sets.

In short: sealed classes give you algebraic data type-style modeling in Kotlin, with compiler-checked exhaustive handling through when.

How do I use sealed classes to control inheritance?

In Java, sealed classes are a feature introduced in Java 15 (as a preview and finalized in Java 17) that allows you to control inheritance by specifying which classes or interfaces can extend or implement a given class or interface. This makes your class hierarchy more predictable and easier to reason about.

Using sealed classes involves the following key concepts:

1. Declaration of a Sealed Class

A class can be declared as sealed, which means that only a specific set of classes (declared permits) can extend that class. Here’s the basic syntax:

public sealed class ParentClass permits ChildA, ChildB {
    // Class code
}

Here, only ChildA and ChildB (declared in permits) are allowed to extend ParentClass. This ensures complete control over the inheritance structure of your class.


2. The Role of Permitted Subclasses

Each subclass specified in the permits clause must do one of the following to complete the sealed hierarchy:

  • Declare itself as final (no further inheritance is allowed).
  • Declare itself as sealed (allowing further controlled inheritance).
  • Declare itself as non-sealed (allowing unrestricted inheritance).

Examples of each:

Final Subclass:

public final class ChildA extends ParentClass {
    // Class code
}

Sealed Subclass:

public sealed class ChildB extends ParentClass permits GrandChild {
    // Class code
}

public final class GrandChild extends ChildB {
    // Class code
}

Non-Sealed Subclass:

public non-sealed class ChildC extends ParentClass {
    // Class code
}

In the case of non-sealed, ChildC and its subclasses can be freely inherited, bypassing the restrictions of sealing.


3. Key Features and Benefits of Sealed Classes

  1. Ensure Complete Class Hierarchy Control:
    • By listing all allowed subclasses, you can restrict who can build upon your functionality.
    • Simplifies reasoning about the class hierarchy in complex systems.
  2. Improved Exhaustiveness Checking:
    • When used with instanceof or switch expressions, the compiler knows all the possible subclasses (because they’ve been explicitly listed).
    • For example, pattern matching with switch:
    public String process(ParentClass obj) {
         return switch (obj) {
             case ChildA a -> "ChildA";
             case ChildB b -> "ChildB";
             default -> throw new IllegalStateException("Unexpected value: " + obj);
         };
     }
    
  3. Enforces Encapsulation and API Design Consistency:
    • Encourages developers to think hard about which subclasses make sense.
  4. Useful for Modeling Closed Systems:
    • Great for scenarios where the possible subclasses represent a closed set of types, such as states in a state machine.

Example: Sealed Class for a Shape Hierarchy

Here is a practical example of using sealed classes in a geometric shape hierarchy:

public sealed class Shape permits Circle, Rectangle, Square {
    // Common shape fields and methods
}

public final class Circle extends Shape {
    // Circle-specific fields and methods
}

public final class Rectangle extends Shape {
    // Rectangle-specific fields and methods
}

public final class Square extends Shape {
    // Square-specific fields and methods
}

If someone tries to create a new subclass of Shape outside of those specified in permits, a compilation error will occur.


4. Rules and Restrictions

  • A sealed class must use the permits clause unless all permitted implementations are within the same file.
  • The permitted classes must extend the sealed class or implement the sealed interface.
  • Subclasses of sealed classes located in different packages must be public.
  • All permitted classes are resolved at compile time.

Summary

Java’s sealed classes provide you with a powerful tool to control inheritance in your programs by explicitly defining the classes that are allowed to extend or implement a particular class or interface. They make your code more robust, predictable, and maintainable by restricting which subclasses can exist in a hierarchy. Use them when you want tight control over a class hierarchy or when modeling scenarios with a limited set of possibilities.

How to use sealed classes for better type safety in Java 17

Java 17 introduced sealed classes as part of the enhancements to the type system. Sealed classes allow developers to explicitly control which classes can extend or implement a class or interface, thereby achieving better type safety and making it easier to design domain-specific hierarchies. Here’s a guide on how to use sealed classes effectively:


What are Sealed Classes?

Sealed classes restrict which other classes or interfaces can extend or implement them. By using sealed classes, you can:

  1. Define a closed hierarchy of types where only a fixed set of subtypes is allowed.
  2. Ensure better maintainability and readability of your type hierarchy.
  3. Provide exhaustive handling for these types with features like switch statements.

The syntax revolves around the sealed, non-sealed, and final keywords.


Declaring and Using Sealed Classes

1. Declaration

To declare a sealed class:

  • Use the sealed modifier.
  • Specify the permitted subclasses with the permits clause.
package org.kodejava.basic;

public sealed class Shape permits Circle, Rectangle, Square {
   // Common properties and methods for all shapes
}

In this example:

  • Shape is the sealed class.
  • Only Circle, Rectangle, and Square are allowed to extend Shape.

2. Permitted Subclasses

Every subclass permitted by the sealed class must opt for one of the following:

  • final: The subclass cannot be further extended.
  • non-sealed: The subclass can be extended by any other class.
  • sealed: The subclass restricts its hierarchy further with permits.

Examples:

package org.kodejava.basic;

// Final subclass (cannot have further subclasses)
public final class Circle extends Shape {
   double radius;

   public Circle(double radius) {
      this.radius = radius;
   }
}
package org.kodejava.basic;

// Sealed subclass with its own permitted subclasses
public sealed class Rectangle extends Shape permits RoundedRectangle {
   double width, height;

   public Rectangle(double width, double height) {
      this.width = width;
      this.height = height;
   }
}
package org.kodejava.basic;

// Non-sealed subclass (can have arbitrary subclasses)
public non-sealed class Square extends Shape {
   double side;

   public Square(double side) {
      this.side = side;
   }
}
package org.kodejava.basic;

// Permitted subclass of Rectangle
public final class RoundedRectangle extends Rectangle {
    double cornerRadius;

    public RoundedRectangle(double width, double height, double cornerRadius) {
        super(width, height);
        this.cornerRadius = cornerRadius;
    }
}

Benefits of Sealed Classes

  1. Closed Type Hierarchies
    Sealed classes provide an explicit way to define and restrict type hierarchies, avoiding unintended subclasses.

  2. Exhaustiveness in switch Statements
    When all subclasses of a sealed class are known, the compiler ensures exhaustiveness in switch expressions. This helps eliminate the possibility of missing a case.

    Example:

    public double calculateArea(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius * c.radius;
            case RoundedRectangle rr ->
                    rr.width * rr.height - (4 - Math.PI) * rr.cornerRadius * rr.cornerRadius / 4;
            case Rectangle r -> r.width * r.height;
            case Square s -> s.side * s.side;
            default -> throw new IllegalStateException("Unexpected value: " + shape);
        };
    }
    

    If you later add a new subclass to Shape, the compiler will generate an error until you update the switch statement accordingly.

  3. Immutability and Security
    By marking direct subclasses as final or controlling further inheritance (e.g., via sealed vs. non-sealed), you ensure immutability in specific contexts and prevent unintended behavior caused by subclassing.


Practical Use Cases for Sealed Classes

  1. Domain Modelling
    Example: A sealed class Payment can have subclasses for CreditCardPayment, BankTransfer, and CryptoPayment.

    public sealed class Payment permits CreditCardPayment, BankTransfer, CryptoPayment {
       // Common payment attributes
    }
    
    public final class CreditCardPayment extends Payment {
       // Credit card specific fields
    }
    
    public final class BankTransfer extends Payment {
       // Bank transfer specific fields
    }
    
    public final class CryptoPayment extends Payment {
       // Crypto payment specific fields
    }
    
  2. Compiler Assistance for Type-Safe Code
    The sealed hierarchy ensures that when you process these types (e.g., with switch or polymorphic methods), the compiler helps enforce exhaustive handling.


Key Points to Remember

  • You must list all permitted subclasses explicitly using the permits clause.
  • All subclasses of a sealed class must be declared in the same module or package as the sealed class (enhanced encapsulation).
  • sealed, non-sealed, and final define the inheritance relation for permitted subclasses.

Summary

Sealed classes are a powerful tool in Java 17 for creating controlled and predictable type hierarchies. They help enforce constraints at compile-time, reduce runtime errors, and assist developers in creating clean, maintainable, and type-safe code. Use them effectively to create robust domain models and application logic.