One of the stand-out fundamental features in Kotlin are sealed classes. Sealed classes allow developers to define a closed set of subclasses, making it easier to manage and maintain complex codebases. This article delves into the fundamentals of sealed classes, exploring their advantages, implementation, common use cases, and best practices.

By the end, you’ll have a solid understanding of how to leverage sealed classes to write more robust and maintainable code.

What Is A Sealed Class?

Sealed class in Kotlin allows developers to create a restricted class hierarchy. Unlike regular classes, sealed classes can only be subclassed within the same package where they are declared. This ensures that all possible subclasses are known at compile-time, providing better type safety and exhaustiveness checks when dealing with class hierarchies.

A sealed class is declared using the sealed keyword, and its subclasses are typically defined as either classes or objects within the same file. This setup helps in representing a fixed set of possible types, similar to enums but with the flexibility of classes. For example, sealed classes are particularly useful in scenarios where you have a limited set of operations that can be performed, and you want to enforce this restriction throughout your codebase.

1
2
3
4
sealed class Shape
class Circle(val radius: Double) : Shape()
class Rectangle(val width: Double, val height: Double) : Shape()
object Unknown : Shape()

In this example, Shape is a sealed class with three possible types: Circle, Rectangle, and a singleton object Unknown. The compiler knows all possible subclasses of Shape, enabling it to perform exhaustive checks e.g. when you use a when expression to handle different shapes, you can be sure all cases are covered, or the compiler will warn you if you miss any.

Sealed classes thus provide a way to model complex data structures while maintaining type safety and ensuring your code is robust and less prone to errors. They strike a balance between the simplicity of enums and the flexibility of class inheritance, making them a useful tool in every Kotlin developer’s toolbox.

Why Not Just Enums?

Enums are suitable for representing a fixed set of constants, but they lack the flexibility of sealed classes. Enums cannot have properties or methods specific to each constant, whereas sealed classes allow each subclass to have its own properties and methods. This makes sealed classes more versatile for modeling complex data structures. However, enums are simpler and more concise when you only need to represent a small, fixed set of values without additional behavior.

Advantages Of Using Sealed Classes

Sealed classes offer several advantages that make them an attractive choice for developers. Firstly, they provide exhaustive when expressions. Because all subclasses of a sealed class are known at compile-time, the compiler can enforce that all possible cases are handled. This reduces the likelihood of runtime errors and makes the code more robust.

Another advantage is improved readability and maintainability. Sealed classes allow you to define a clear and constrained hierarchy, making it easier to understand the possible types and their relationships. This can significantly reduce the cognitive load on developers, as they do not need to consider unknown subclasses scattered across the codebase. All possible subclasses are defined in one package, providing a clear and concise overview.

Sealed classes also enhance type safety. By restricting the set of subclasses, sealed classes ensure that you can only work with a known set of types. This reduces the risk of type-related bugs and makes the code easier to reason about. For instance, if you add a new subclass, the compiler will alert you to update all relevant expressions, ensuring that your code remains consistent and correct.

Furthermore, sealed classes can encapsulate state and behavior more effectively than enums. While enums are useful for representing a fixed set of constants, sealed classes offer more flexibility by allowing each subclass to have its own properties and methods. This makes sealed classes ideal for modeling complex data structures and domain-specific logic.

Examples

Error Handling

Imagine you’re building a data-fetching function that could either succeed or fail. With sealed classes, you can create a clean, type-safe representation of these outcomes.

We’ll define a Result sealed class with two subclasses: Success and Error. The Success class will hold the successfully fetched data, while the Error class will contain information about what went wrong.

1
2
3
4
sealed class Result<out T> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val reason: String) : Result<Nothing>()
}

This setup allows you to return a Result object from your functions, ensuring that calling code must handle both success and failure cases. It’s a compile-time guarantee that you won’t forget to deal with errors.

Using this Result type, you can write functions that clearly express their potential outcomes:

1
2
3
4
5
fun fetchUser(id: String) = try {
    Success(getUser(id))
} catch (e: Exception) {
    Error("Failed to fetch user: ${e.message}")
}

When using this function, you’re forced to handle both cases, leading to more robust code:

1
2
3
4
when (val result = fetchUser("123")) {
    is Success -> displayUser(result.data)
    is Error -> showErrorMessage(result.reason)
}

This pattern promotes cleaner, more maintainable code by making error handling explicit and encouraging developers to consider all possible outcomes. It’s a prime example of how Kotlin’s sealed classes can improve code quality and reduce bugs related to unexpected states or unhandled errors.

Modelling System Commands

Sealed classes in Kotlin prove invaluable when modeling system commands, especially in scenarios where you need to represent a finite set of operations. Let’s explore how we can use sealed classes to create a robust command structure for a hypothetical file system manager.

Imagine you’re building a file system utility that needs to handle various operations like creating directories, deleting files, and moving items. Each of these commands might require different parameters and behave uniquely. Sealed classes offer a neat solution to model these diverse yet related commands.

Here’s how you might structure your system commands:

1
2
3
4
5
6
sealed class FileSystemCommand {
    data class CreateDirectory(val path: String) : FileSystemCommand()
    data class DeleteFile(val path: String) : FileSystemCommand()
    data class MoveItem(val sourcePath: String, val destinationPath: String) : FileSystemCommand()
    object ListContents : FileSystemCommand()
}

This structure provides several benefits. First, it ensures type safety – you can only create commands that are explicitly defined. Second, it allows you to use Kotlin’s when expression to handle each command type exhaustively, preventing oversight of any command type.

You can then create a command processor that handles these commands:

1
2
3
4
5
6
fun processCommand(command: FileSystemCommand) = when (command) {
    is CreateDirectory -> createDir(command.path)
    is DeleteFile -> deleteFile(command.path)
    is MoveItem -> moveItem(command.sourcePath, command.destinationPath)
    is ListContents -> listContents()
}

This approach offers clear advantages in terms of code organization and maintainability. As your system grows, you can easily add new command types without modifying existing code, adhering to the open-closed principle. It also provides a clear interface for other parts of your application to interact with the file system operations, enhancing modularity and testability.

Best Practices

To get the most out of sealed classes in Kotlin, it’s essential to follow best practices that ensure your code is clean, maintainable, and efficient.

Define In The Same File

This enforces the restriction that sealed classes provide and ensures the compiler can perform exhaustive checks. Although you can define sealed classes in a package defining them in one file improves readability by keeping the related types together.

Use For Representing Fixed Hierarchies

Sealed classes are ideal for scenarios where you have a well-defined set of possible types. Use them to model state machines, command patterns, or any situation where the set of types is fixed and known at compile-time.

Leverage Exhaustive when

Take advantage of the compiler’s ability to check for exhaustiveness in when expressions.

1
2
3
4
5
fun describeShape(shape: Shape) = when (shape) {
    is Circle -> "Circle with radius ${shape.radius}"
    is Rectangle -> "Rectangle with width ${shape.width} and height ${shape.height}"
    Unknown -> "Unknown Shape"
}

Ensure that all possible subclasses are handled, either by explicitly listing them or using the else branch for completeness. This reduces the risk of runtime errors and makes your code more robust.

Avoid Overuse

While sealed classes are powerful, they are not always the best solution. Avoid overusing them in situations where other class types, such as enums, interfaces, or abstract classes, might be more appropriate. Use sealed classes when the benefits of type safety and controlled hierarchies are most relevant.

Conclusion

Sealed classes are a powerful feature in Kotlin that provide a way to represent restricted class hierarchies with enhanced type safety and maintainability. By allowing only a fixed set of subclasses, sealed classes enable exhaustive expressions and improve code readability. They are particularly useful for modeling state machines, algebraic data types, and complex states. By understanding and implementing sealed classes effectively, you can leverage their advantages to create robust and maintainable Kotlin applications. Embrace sealed classes in your Kotlin projects to ensure a more structured and error-resistant codebase.