In the previous part, we discussed the out and in variance modifiers and their practical use cases.
Consider that you need to define a linked list data structure, which is a type of collection constructed by two types:
node, which represents a linked list with at least one element and includes a reference to the first element (head) and a reference to the rest of the elements (tail).
empty, which represents an empty linked list.
In Kotlin, we would represent such a data structure with a sealed class.
sealed class LinkedList<T>
data class Node<T>(
val head: T,
val tail: LinkedList<T>
) : LinkedList<T>()
class Empty<T> : LinkedList<T>()
fun main() {
val strs = Node("A", Node("B", Empty()))
val ints = Node(1, Node(2, Empty()))
val empty: LinkedList<Char> = Empty()
}
There is one problem though: for every linked list, we need a new object to represent the empty list. Every time we use Empty(), we create a new instance. We would prefer to have only one instance that would serve wherever we need to represent an empty linked list. For that, we use object declaration in Kotlin, but object declarations cannot have type parameters.
sealed class LinkedList<T>
data class Node<T>(
val head: T,
val tail: LinkedList<T>
) : LinkedList<T>()
object Empty<T> : LinkedList<T>() // Error
There is a solution to this problem. We can make the LinkedList type parameter covariant (by adding the out modifier’). This is perfectly fine for a type that is only returned, i.e., for all type parameters in immutable classes. Then, we should make our Empty object extend LinkedList<Nothing>. The Nothing type is a subtype of all types; so, if the LinkedList type parameter is covariant, then LinkedList<Nothing> is a subtype of all linked lists.
sealed class LinkedList<out T>
data class Node<T>(
val head: T,
val tail: LinkedList<T>
) : LinkedList<T>()
object Empty : LinkedList<Nothing>()
fun main() {
val strs = Node("A", Node("B", Empty))
val ints = Node(1, Node(2, Empty))
val empty: LinkedList<Char> = Empty
}
This pattern is used in many places, even in the Kotlin Standard Library. As you already know, List is covariant because it is read-only. When you create a list using listOf or emptyList, they both return the same object, EmptyList, which implements List<Nothing>, therefore EmptyList is a subtype of all lists.
Every empty list created with the listOf or emptyList functions from Kotlin stdlib is actually the same object.
fun main() {
val empty: List<Nothing> = emptyList()
val strs: List<String> = empty
val ints: List<Int> = empty
val other: List<Char> = emptyList()
println(empty === other) // true
}
This pattern occurs in many places; for instance, when we define generic messages and some of them don't need to include any parameters, we should make them objects.
sealed interface ChangesTrackerMessage<out T>
data class Change<T>(val newValue: T) : ChangesTrackerMessage<T>
data object Reset : ChangesTrackerMessage<Nothing>
data object UndoChange : ChangesTrackerMessage<Nothing>
sealed interface SchedulerMessage<out T>
data class Schedule<T>(val task: Task<T>) : SchedulerMessage<T>
data class Delete(val taskId: String) : SchedulerMessage<Nothing>
data object StartScheduled : SchedulerMessage<Nothing>
data object Reset : SchedulerMessage<Nothing>
Even though this pattern repeats in Kotlin projects, I couldn't find a name that would describe it. I decided to name it the "Covariant Nothing Object". This is not a precise name; a precise description would be "pattern in which the object declaration implements a generic class or interface with the Nothing type argument used in the covariant type argument position". Nevertheless, the name needs to be short, and "Covariant Nothing Object" is clear and catchy.
Another example of a Covariant Nothing Object comes from a library my team co-created. It was used to schedule tasks in a microservice environment. For simplicity, you could assume that each task can be modeled as follows:
data class Task<T>(
val id: String,
val scheduleAt: Instant,
val data: T,
val priority: Int,
val maxRetries: Int? = null
)
We needed to implement a mechanism to change scheduled tasks. We also needed to represent change within a configuration in order to define updates and pass them around conveniently. The old-school approach is to make a TaskUpdate class which uses null as a marker which indicates that a specific property should not change.
data class TaskUpdate<T>(
val id: String? = null,
val scheduleAt: Instant? = null,
val data: T? = null,
val priority: Int? = null,
val maxRetries: Int? = null
)
This approach is very limiting. Since the null value is interpreted as "do not change this property", there is no way to express that you want to set a particular value to null. Instead, we used a Covariant Nothing Object in our project to represent a property change. Each property might be either kept unchanged or changed to a new value. We can represent these two options with a sealed hierarchy, and thanks to generic types we might expect specific types of values.
data class TaskUpdate<T>(
val id: TaskPropertyUpdate<String> = Keep,
val scheduleAt: TaskPropertyUpdate<Instant> = Keep,
val data: TaskPropertyUpdate<T> = Keep,
val priority: TaskPropertyUpdate<Int> = Keep,
val maxRetries: TaskPropertyUpdate<Int?> = Keep
)
sealed interface TaskPropertyUpdate<out T>
data object Keep : TaskPropertyUpdate<Nothing>
data class ChangeTo<T>(val newValue: T) : TaskPropertyUpdate<T>
val update = TaskUpdate<String>(
id = ChangeTo("456"),
maxRetries = ChangeTo(null), // we can change to null
data = ChangeTo(123), // COMPILATION ERROR
// type mismatch, expecting String
priority = ChangeTo(null), // COMPILATION ERROR
// type mismatch, property is not nullable
)
This way, we achieved a type-safe and expressive way of representing task changes. What is more, when we use the Covariant Nothing Object pattern, we can easily express other kinds of changes as well. For instance, if our library supports default values or allows a previous value to be restored, we could add new objects to represent these property changes.
data class TaskUpdate<T>(
val id: TaskPropertyUpdate<String> = Keep,
val scheduleAt: TaskPropertyUpdate<Instant> = Keep,
val data: TaskPropertyUpdate<T> = Keep,
val priority: TaskPropertyUpdate<Int> = Keep,
val maxRetries: TaskPropertyUpdate<Int?> = Keep
)
sealed interface TaskPropertyUpdate<out T>
data object Keep : TaskPropertyUpdate<Nothing>
data class ChangeTo<T>(val newValue: T) : TaskPropertyUpdate<T>
data object RestorePrevious : TaskPropertyUpdate<Nothing>
data object RestoreDefault : TaskPropertyUpdate<Nothing>
val update = TaskUpdate<String>(
data = ChangeTo("ABC"),
maxRetries = RestorePrevious,
priority = RestoreDefault,
)
The Covariant Nothing Class
There are also cases where we want a class to implement a class or interface which uses the Nothing type argument as a covariant type parameter. This is a pattern I call the Covariant Nothing Class. For example, consider the Either class, which can be either Left or Right and must have two type parameters that specify what data types it expects on the Left and on the Right. However, both Left and Right should each have only one type parameter to specify what type they expect. To make this work, we need to fill the missing type argument with Nothing.
sealed class Either<out L, out R>
data class Left<out L>(val value: L) : Either<L, Nothing>()
data class Right<out R>(val value: R) : Either<Nothing, R>()
With such definitions, we can create Left or Right without specifying type arguments.
val left = Left(Error())
val right = Right("ABC")
Both Left and Right can be up-casted to Left and Right with supertypes of the types of values they hold.
val leftError: Left<Error> = Left(Error())
val leftThrowable: Left<Throwable> = leftError
val leftAny: Left<Any> = leftThrowable
val rightInt = Right(123)
val rightNumber: Right<Number> = rightInt
val rightAny: Right<Any> = rightNumber
They can also be used wherever a result with the appropriate Left or Right type is expected.
val leftError: Left<Error> = Left(Error())
val rightInt = Right(123)
val el: Either<Error, Int> = leftError
val er: Either<Error, Int> = rightInt
val etnl: Either<Throwable, Number> = leftError
val etnr: Either<Throwable, Number> = rightInt
This, in simplification, is how Either is implemented in the Arrow library.
This ends the second part of this series. In the next part, we will discuss the limitations of variance modifiers.
Marcin Moskala is a highly experienced developer and Kotlin instructor as the founder of Kt. Academy, an official JetBrains partner specializing in Kotlin training, Google Developers Expert, known for his significant contributions to the Kotlin community. Moskala is the author of several widely recognized books, including "Effective Kotlin," "Kotlin Coroutines," "Functional Kotlin," "Advanced Kotlin," "Kotlin Essentials," and "Android Development with Kotlin."
Beyond his literary achievements, Moskala is the author of the largest Medium publication dedicated to Kotlin. As a respected speaker, he has been invited to share his insights at numerous programming conferences, including events such as Droidcon and the prestigious Kotlin Conf, the premier conference dedicated to the Kotlin programming language.
Software architect with 15 years of experience, currently working on building infrastructure for AI. I think Kotlin is one of the best programming languages ever created.
Owen has been developing software since the mid 1990s and remembers the productivity of languages such as Clipper and Borland Delphi.
Since 2001, He moved to Web, Server based Java and the Open Source revolution.
With many years of commercial Java experience, He picked up on Kotlin in early 2015.
After taking detours into Clojure and Scala, like Goldilocks, He thinks Kotlin is just right and tastes the best.
Owen enthusiastically helps Kotlin developers continue to succeed.