I wrote a blog post in Typescript on leveraging Union types to remove impossible states.
Since then I have moved into Kotlin development.
Kotlin too has a great type system and a lot more language features to model data in creative ways.
So here goes a post detailing some cool and similar things we can do with Kotlin.
For a long time, most languages allowed any variable to take a null value. This by design makes your code unsafe to work on. NullPointerException has been a bane for us programmers.
Turns out that we needed the ability to model a value that could be absent at runtime.
Enter Optional values
The optional type gives us developers to model data that could be absent.
Let's take an example -
Say we want to represent Car
we could model it something like this:
class Car(val name: String, val trunkSpace: Double, val frunkSpace: Double?)
Now let's say we need to get total space in the car, we can do something like this:
fun getCarSpace(car: Car): Double {
return car.trunkSpace + car.frunkSpace // Compiler error!
}
See the question mark at the beginning we introduced while modeling the data. It just saved us from making a grave mistake.
We did a good thing by making it questionable, didn’t we? - pun intended.
The question mark is a way of telling Kotlin that the value can be null. It fails to compile until we handle the case when the Car doesn’t have a frunk.
Let's fix this shall we:
fun getCarSpace(car: Car): Double {
return car.trunkSpace + (car.frunkSpace ?: 0.0);
}
?: -
operator is called the Elvis operator! Man, Kotlin people have a good sense of humor.
We just saved ourselves from a NullPointerException.
Is that all?
Let’s see if we can improve this.
Enter Polymorphism
The above way of using optional param is good and spares us devs a lot of pain.
But if we look at the function that gets us the total space has to be aware of the fact that there could be frunkSpace in the first place. This is what we call a “leaky” abstraction.
So how do we avoid this?
Well if we had created two different types of cars in the first place we wouldn't be in this situation! Let's see how :
sealed class Car(val name: String)
class CombustionCar(name: String, val trunkSpace: Double) : Car(name)
class ElectricCar(name: String, val trunkSpace: Double, val frunkSpace: Double) : Car(name)
fun getCarSpace(car: Car): Double {
return when(car) {
is CombustionCar -> car.trunkSpace // can't access frunk space here!
is ElectricCar -> car.trunkSpace + car.frunkSpace
else -> 0.0
}
}
Well, this looks better. Except for the “else” clause. Since we only have those two types of cars it doesn’t make sense why we would need to handle the fall-through case.
Well Kotlin compiler doesn’t know that there are only two types of Car - Someone could have extended it someplace else i.e. different package or module.
So how do we tell the Kotlin compiler that there are only two types - the ones we declared 🤔
Enter Sealed classes
sealed class Car(val name: String)
class CombustionCar(name: String, val trunkSpace: Double) : Car(name)
class ElectricCar(name: String, val trunkSpace: Double, val frunkSpace: Double) : Car(name)
fun getCarSpace(car: Car): Double =
when (car) {
is CombustionCar -> car.trunkSpace
is ElectricCar -> car.trunkSpace + car.frunkSpace // no need to handle "else" clause!
}
Since we are in Kotlin we can define a custom-getter on the sealed
class something like this:
sealed class Car(val name: String) {
abstract val size: Double
}
class CombustionCar(name: String, val trunkSpace: Double) : Car(name) {
override val size: Double
get() = trunkSpace
}
class ElectricCar(name: String, val trunkSpace: Double, val frunkSpace: Double) : Car(name) {
override val size: Double
get() = trunkSpace + frunkSpace
}
So the consumer code becomes quite concise :
val car1 = CombustionCar("Ambassador", 2.0)
val car2 = ElectricCar("Tesla", 2.0, 1.5)
println("Car Space in ${car1.name}: ${car1.size}")
println("Car Space in ${car2.name}: ${car2.size}")
The above is called “information hiding” - as in consumers don’t have to be aware of exactly size
is calculated.
Now this allows us to compose in better ways.
Say we need to get total boot space available from different cars in a parking lot. We could do something like this:
val carsInParkingLot = listOf<Car>(
ElectricCar("Tesla", 2.0, 1.4),
CombustionCar("Kia", 4.0),
CombustionCar("Ford EcoSport", 2.0)
)
val totalSpace = carsInParkingLot.sumOf { it.size }
println("Total space: $totalSpace") // should output 9.4
Well, all this looks great.
To make things more interesting let's add range to our Car
class:
sealed class Car(val name: String, val range: Double) {
abstract val size: Double
}
class CombustionCar(name: String, val trunkSpace: Double, carRange: Double) : Car(name, carRange) {
override val size: Double
get() = trunkSpace
}
class ElectricCar(name: String, val trunkSpace: Double, val frunkSpace: Double, carRange: Double) : Car(name, carRange) {
override val size: Double
get() = trunkSpace + frunkSpace
}
Let’s calculate the total range of the cars in the parking lot now:
val carsInParkingLot = listOf(
ElectricCar("Tesla", 2.0, 1.4, 200.0),
CombustionCar("Kia", 4.0, 200.0),
CombustionCar("Ford EcoSport", 2.0, 1000.0)
)
val totalRange = carsInParkingLot.sumOf { it.size }
println("Total space: $totalRange")
Can you spot the mistake I made?
Yup! I calculated the sum of size instead of range!
The code compiled fine because size and range are both of Double
type.
Well, you can add tests to make sure this doesn’t happen.
Can we do better?
Enter Value Types
We don't have to use primitive types to directly represent domain types.
We can do something like this:
data class Range(val value: Double)
And Kotlin
lets you define custom operators - so we can define a custom plus operator on the above -
data class Range(val value: Double) {
override fun toString() = value.toString()
operator fun plus(other: Range) = Range(value + other.value)
}
So this lets us do something like this:
val car1 =
ElectricCar("Tesla", 2.0, 1.4, Range(200.0))
val car2 =
CombustionCar("Kia", 4.0, Range(200.0))
val range = car1.range + car2.range
println("Range: $range")
Isn’t that cool? Wrapping the primitive value in a custom type and defining some additional methods allows us to write fewer tests!
Because you cannot get Range
by adding a bunch of sizes
!
Now let’s fix do what we set out to do in the first place, getting the sum of the range of cars in the parking lot:
val carsInParkingLot = listOf(
ElectricCar("Tesla", 2.0, 1.4, Range(200.0)),
CombustionCar("Kia", 4.0, Range(200.0)),
CombustionCar("Ford EcoSport", 2.0, Range(1000.0))
)
val totalRange = carsInParkingLot.fold(Range(0.0)) { acc, curr -> acc + curr.range }
println("Total Range: $totalRange")
Et Voila!
I got a lot of these cool tricks from - Joy of Kotlin
book. I highly recommend it to folks who are looking to improve their Kotlin skills.