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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
- 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.
- 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.
- 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
- 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.
- 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.
- 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!