Kotlin Sealed Interfaces: A Deep Dive into a Powerful New Feature

Table of Contents

When Kotlin was first introduced, developers quickly fell in love with its powerful language features, including sealed classes. However, there was one thing that seemed to be missing: sealed interfaces. At the time, the Kotlin compiler was unable to guarantee that someone couldn’t implement an interface in Java code, which made it difficult to implement sealed interfaces in Kotlin.

But times have changed, and now sealed interfaces are finally available in both Kotlin 1.5 and Java 15 onwards. With sealed interfaces, developers can create more robust and type-safe APIs, just like they could with sealed classes. In this blog post, we’ll take a deep dive into Kotlin sealed interfaces and explore how they can help you build better code. We’ll cover everything from the basics of sealed interfaces to advanced techniques and best practices, so get ready to master this powerful new feature!

Basics of Sealed Interfaces in Kotlin

Like sealed classes, sealed interfaces provide a way to define a closed hierarchy of types, where all the possible subtypes are known at compile time. This makes it possible to create more robust and type-safe APIs, while also ensuring that all the possible use cases are covered.

To create a sealed interface in Kotlin, you can use the sealed modifier before the interface keyword. Here\’s an example:

Kotlin
sealed interface Shape {
    fun draw()
}

This creates a sealed interface called Shape with a single method draw(). Note that sealed interfaces can have abstract methods, just like regular interfaces. A sealed interface can only be implemented by classes or objects that are declared within the same file or the same package as the sealed interface itself.

Now, let’s see how we can use a sealed interface in practice. Here’s an example:

Kotlin
sealed interface Shape {
    fun area(): Double
}

class Circle(val radius: Double) : Shape {
    override fun area() = Math.PI * radius * radius
}

class Rectangle(val width: Double, val height: Double) : Shape {
    override fun area() = width * height
}

fun calculateArea(shape: Shape): Double {
    return shape.area()
}

In this example, we define a sealed interface named Shape that has a single abstract method named area(). We then define two classes that implement the Shape interface: Circle and Rectangle. Finally, we define a function named calculateArea() that takes an argument of type Shape and returns the area of the shape.

Since the Shape interface is sealed, we cannot implement it outside the current file or package. This means that only the Circle and Rectangle classes can implement the Shape interface.

Sealed interfaces are particularly useful when we want to define a set of related interfaces that can only be implemented by a specific set of classes or objects. For example, we could define a sealed interface named Serializable that can only be implemented by classes that are designed to be serialized.

Subtypes of Sealed Interfaces

To create subtypes of a sealed interface, you can use the sealed modifier before the class keyword, just like with sealed classes. Here\’s an example:

Kotlin
sealed interface Shape {
    fun draw()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

sealed class Square : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class RoundedSquare : Square() {
    override fun draw() {
        println("Drawing a rounded square")
    }
}

This creates two sealed classes Circle and Square that implement the Shape interface, as well as a non-sealed class RoundedSquare that extends Square. Note that RoundedSquare is not a sealed class, since it doesn\’t have any direct subtypes.

Using Sealed Interfaces with When Expressions

One of the main benefits of sealed interfaces (and sealed classes) is that they can be used with when expressions to provide exhaustive pattern matching. Here\’s an example:

Kotlin
fun drawShape(shape: Shape) {
    when(shape) {
        is Circle -> shape.draw()
        is Square -> shape.draw()
        is RoundedSquare -> shape.draw()
    }
}

This function takes a Shape as a parameter and uses a when expression to call the appropriate draw() method based on the subtype of the shape. Note that since Shape is a sealed interface, the when expression is exhaustive, which means that all possible subtypes are covered.

Advanced Techniques and Best Practices

While sealed interfaces provide a powerful tool for creating type-safe APIs, there are some advanced techniques and best practices to keep in mind when working with them.

Interface Delegation

One technique that can be used with sealed interfaces is interface delegation. This involves creating a separate class that implements the sealed interface, and then delegating calls to the appropriate methods to another object. Here’s an example:

Kotlin
sealed interface Shape {
    fun draw()
}

class CircleDrawer : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

class SquareDrawer : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class DrawingTool(private val shape: Shape) : Shape by shape {
    fun draw() {
        shape.draw()
        // additional drawing logic here
    }
}

In this example, we’ve created two classes CircleDrawer and SquareDrawer that implement the Shape interface. We\’ve then created a class DrawingTool that takes a Shape as a parameter and delegates calls to the draw() method to that shape. Note that DrawingTool also includes additional drawing logic that is executed after the shape is drawn.

Avoiding Subclassing

Another best practice to keep in mind when working with sealed interfaces is to avoid subclassing whenever possible. While sealed interfaces can be used to create closed hierarchies of subtypes, it’s often better to use composition instead of inheritance to achieve the same effect.

For example, consider the following sealed interface hierarchy:

Kotlin
sealed interface Shape {
    fun draw()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

sealed class Square : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class RoundedSquare : Square() {
    override fun draw() {
        println("Drawing a rounded square")
    }
}

While this hierarchy is closed and type-safe, it can also be inflexible if you need to add new types or behaviors. Instead, you could use composition to achieve the same effect:

Kotlin
sealed interface Shape {
    fun draw()
}

class CircleDrawer : (Circle) -> Unit {
    override fun invoke(circle: Circle) {
        println("Drawing a circle")
    }
}

class SquareDrawer : (Square) -> Unit {
    override fun invoke(square: Square) {
        println("Drawing a square")
    }
}

class RoundedSquareDrawer : (RoundedSquare) -> Unit {
    override fun invoke(roundedSquare: RoundedSquare) {
        println("Drawing a rounded square")
    }
}

class DrawingTool(private val drawer: (Shape) -> Unit) {
    fun draw(shape: Shape) {
        drawer(shape)
        // additional drawing logic here
    }
}

In this example, we’ve created separate classes for each type of shape, as well as a DrawingTool class that takes a function that knows how to draw a shape. This approach is more flexible than using a closed hierarchy of subtypes, since it allows you to add new shapes or behaviors without modifying existing code.

Extending Sealed Interfaces

Finally, it’s worth noting that sealed interfaces can be extended just like regular interfaces. This can be useful if you need to add new behaviors to a sealed interface without breaking existing code. Here’s an example:

Kotlin
sealed interface Shape {
    fun draw()
}

interface FillableShape : Shape {
    fun fill()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

class FilledCircle : Circle(), FillableShape {
    override fun fill() {
        println("Filling a circle")
    }
}

In this example, we’ve extended the Shape interface with a new FillableShape interface that includes a fill() method. We\’ve then created a new FilledCircle class that extends Circle and implements FillableShape. This allows us to add a new behavior (fill()) to the Shape hierarchy without breaking existing code.

Sealed Classes vs Sealed Interfaces

Sealed classes and sealed interfaces are both Kotlin language features that provide a way to restrict the possible types of a variable or a function parameter. However, there are some important differences between the two.

A sealed class is a class that can be extended by a finite number of subclasses. When we declare a class as sealed, it means that all possible subclasses of that class must be declared within the same file as the sealed class itself. This makes it possible to use the subclasses of the sealed class in a when expression, ensuring that all possible cases are handled.

Here’s an example of a sealed class:

Kotlin
sealed class Vehicle {
    abstract fun accelerate()
}

class Car : Vehicle() {
    override fun accelerate() {
        println("The car is accelerating")
    }
}

class Bicycle : Vehicle() {
    override fun accelerate() {
        println("The bicycle is accelerating")
    }
}

In this example, we declare a sealed class called Vehicle. We also define two subclasses of Vehicle: Car and Bicycle. Because Vehicle is sealed, any other possible subclasses of Vehicle must also be declared in the same file.

On the other hand, a sealed interface is an interface that can be implemented by a finite number of classes or objects. When we declare an interface as sealed, it means that all possible implementations of that interface must be declared within the same file or the same package as the sealed interface itself.

Here’s an example of a sealed interface:

Kotlin
sealed interface Vehicle {
    fun accelerate()
}

class Car : Vehicle {
    override fun accelerate() {
        println("The car is accelerating")
    }
}

object Bicycle : Vehicle {
    override fun accelerate() {
        println("The bicycle is accelerating")
    }
}

In this example, we declare a sealed interface called Vehicle. We also define two implementations of Vehicle: Car and Bicycle. Because Vehicle is sealed, any other possible implementations of Vehicle must also be declared in the same file or package.

One important difference between sealed classes and sealed interfaces is that sealed classes can have state and behavior, while sealed interfaces can only have behavior. This means that sealed classes can have properties, methods, and constructors, while sealed interfaces can only have abstract methods.

Another difference is that sealed classes can be extended by regular classes or other sealed classes, while sealed interfaces can only be implemented by classes or objects. Sealed classes can also have a hierarchy of subclasses, while sealed interfaces can only have a flat list of implementations.

Advantages

  1. Type Safety: Sealed interfaces allow you to define a closed hierarchy of subtypes, which ensures that all possible use cases are covered. This can help you catch errors at compile time, rather than runtime, making your code more robust and easier to maintain.
  2. Flexibility: Sealed interfaces can be used to define complex hierarchies of subtypes, while still allowing you to add new types or behaviors without breaking existing code. This makes it easier to evolve your code over time, without having to make sweeping changes.
  3. Improved API Design: By using sealed interfaces, you can create more intuitive and expressive APIs that better reflect the domain you are working in. This can help make your code easier to read and understand, especially for other developers who may not be as familiar with your codebase.

Disadvantages

  1. Learning Curve: While sealed interfaces are a powerful feature, they can be difficult to understand and use correctly. It may take some time to become comfortable working with sealed interfaces, especially if you’re not used to working with type hierarchies.
  2. Complexity: As your codebase grows and becomes more complex, working with sealed interfaces can become more difficult. This is especially true if you have a large number of subtypes or if you need to modify the hierarchy in a significant way.
  3. Performance: Because sealed interfaces use type checking at runtime to ensure type safety, they can have a performance impact compared to other approaches, such as using enums. However, this impact is usually negligible for most applications.

Conclusion

Sealed interfaces are a powerful new feature in Kotlin that provide a type-safe way to define closed hierarchies of types. By using sealed interfaces, you can create more robust and flexible APIs, while also ensuring that all possible use cases are covered. Remember to use interface delegation, avoid subclassing, and consider extending sealed interfaces when appropriate to get the most out of this powerful new feature!

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!