Extending Kotlin's Null-Safety with Monad Comprehension
A look at the inbuilt null-safety feature in Kotlin, and ways we can improve the ergonomics of working with nullable values
Photo by Piotr Chrobot on Unsplash
Kotlin has a very decent null-safety baked in; and even better, it is a part of its type system.
private fun easy1(str: String?): String {
return str?.let {
return "$str is so easy"
} ?: "Not so easy after all"
}
// We can easily call this function with a null or String value
@Test
fun easyTest() {
assertEquals(easy1(null), "Not so easy after all")
assertEquals(easy1("Life"), "Life is so easy")
}
Looking at the signature of easy1
, it accepts a parameter of type String?
. The ?
means that the parameter can also be null. Inside its function body, we can see the expression str?.let{ <block> } ?: <expr>
, which means, that if str
is not null, run block
else return expr
. There is no direct and safe access to str
without making sure that it is non-null, this in itself is null-safety.
Compare the above example with the following
private fun easy2(str: String): String {
return "$str is so easy"
}
@Test
fun easy2Test() {
// compile error: Null can not be a value of a non-null type String
assertEquals(easy2(null), "Not so easy after all")
assertEquals(easy2("Life"), "Life is so easy")
}
Here, the compiler does not even allow us to call easy2
, because it would not take a null value.
But, what happens when we need to use a couple of nullable values, and only if they are non-nullable. Given:
data class Person(val name: String, val age: Int, val email: String)
interface PersonService {
fun getName(): String?
fun getAge(): Int?
fun getEmail(): String?
fun createPerson(name: String, age: Int, email: String): Person
}
In the listing above, we have a Person
data class and an interface that describes what a PersonService
should look like. Let's try to create a new Person
using an implementation of the PersonService
fun createPerson() {
return personService.getName()?.let { name ->
personService.getAge()?.let { age ->
personService.getEmail()?.let { email ->
personService.createPerson(name, age, email)
}
}
}
}
The ?.let
chains gets out of hand very quickly, and it can be a bit hard to keep track of things. We can build on Kotlin's inbuilt null-safety to provide a more ergonomic way to use nullable values.
First attempt — zip method
fun<T, U> T?.zip(other: U?): Pair<T, U>? =
this?.let { other?.let { Pair(this, other) } }
Taking advantage of the nullable let
bindings, we provide an abstraction that wraps two nullable values into a nullable Pair
of non-null values. Let's see the usage of this method:
@Test
fun zipTest() {
var v1: String? = null
var v2: Int? = null
val res1 = v1.zip(v2)?.let { (a, b) -> "v1 is $a and v2 is $b" }
assertEquals(res1, null)
v1 = "Hi"
v2 = 4
val res2 = v1.zip(v2)?.let { (a, b) -> "v1 is $a and v2 is $b" }
assertEquals(res2, "v1 is Hi and v2 is 4")
v2 = null
val res3 = v1.zip(v2)?.let { (a, b) -> "v1 is $a and v2 is $b" }
assertEquals(res3, null)
}
We start out with two variables, v1
and v2
, that are nullable. At res1
, v1
is zipped with v2
, and both are null
, so the nullable let
binding block doesn't get evaluated because zip
returns null
. The nice part of the zip
method is that, inside the let
block, that a
and b
are both non-nullable. At res2
, both v1
and v2
has been set, so the let
block gets evaluated. At res3
, v2
has been set back to null
, and even though v1
is not null
, zip
still returns null. Let's apply this to our person creation problem:
@Test
fun createPerson2() {
val person: Person? =
personService.getName()
.zip(personService.getAge())
.zip(personService.getEmail())
?.let { (nameAndAgePair, email) ->
val (name, age) = nameAndAgePair
personService.createPerson(name, age, email)
}
}
This comes with a bit of mixed feelings. Already we get some breath of fresh air, in the sense, that we got rid of the nested let
blocks, but combining three nullable values results in a pair of a pair and a value, in this case, a Pair<Pair<String, Int>, String>
type. We then need to do multiple destructuring to get the values out.
We can also not even use destructuring at all, and just access items in the
Pair
with thefirst
andsecond
getters, but we quickly get to the point where we need to dopair.first.first
, which is not as good either.
Second attempt — the NullableScope
and the bind
method (a.k.a. Monad Comprehension)
class NullableScope {
fun<T> T?.bind(): T {
return this ?: throw Exception()
}
}
inline fun<T> nullable(block: NullableScope.() -> T): T? {
return with(NullableScope()) {
try {
block()
} catch (e: Exception) {
null
}
}
}
These are few lines of code, but if you are not used to these kinds of things in Kotlin, it might look very daunting at first. So let's take them apart.
Firstly, we defined a class NullableScope
, which defines a method T?.bind()
, which returns this
or throws an exception if this
is null
. T
is a generic type parameter and T?
is saying that the generic type parameter can be nullable. So we can call bind
on nullable values as well as non-nullable values. This class is used as a scope, within which, any type, T
has access to the bind
method. So outside the NullableScope
, the bind
method, on any type, is not defined.
Secondly, we define a function nullable
with a generic parameter T
. nullable
accepts a block that evaluates to T
, as a parameter, but this block is a special kind. It is not just () -> T
, rather NullableScope.() -> T
, which roughly means that this block can only be resolved only in a NullableScope
context and when evaluated should return a value of type T
. The nullable
method itself returns a nullable T
(T?
).
using
nullable
as the function name makes it hard to read the last paragraph but bear with me. This sacrifice will be worth it
Then, inside the body of the function, we use the Kotlin with
keyword to provide the context NullableScope
and provide a block that wraps the evaluation of the block
parameter in a try catch
block. If any exception is thrown, it is caught and null
is returned. It is essential that we wrap this in a try catch
block, since our bind
method is expected to throw an exception whenever it is called on a null
value. Let's see this in action:
@Test
fun nullableScopeStopsAfterFirstBindOnNull() {
val v1: String? = null
val v2: Int? = null
val v5 = nullable {
val v3: String = v1.bind()
val v4: Int = v2.bind()
v3.slice(0..v4)
}
assertEquals(v5, null)
}
v5
uses the nullable
function to wrap a block, where nullable values can be used as non-nulls by calling bind on them. v5
evaluates to String?
type. This looks very readable and can be processed linearly.
Let's see an example where all variables resolve to a non-nullable
@Test
fun nullableScopeEvaluatesToValueIfNoNullValueWasBound() {
var v1: String? = "value"
val v2: Int? = 2
val v5 = nullable {
val v3 = v1.bind()
val v4 = v2.bind()
v3.slice(0..v4)
}
assertEquals(v5, "val")
}
Super!
But, what if we have some other computation inside the nullable
block, that we would want to throw an exception if something goes wrong? Like below:
@Test
fun `nullable scope does lets exception to be thrown`() {
val v1: String? = null
val v2: Int? = null
var count = 0
val v5 = {
nullable {
if (count == 0) {
throw Exception()
}
val v3 = v1.bind()
val v4 = v2.bind()
v3.slice(0..v4)
}
}
assertEquals(count, 0)
// fails with `Expected Exception to be thrown,
// but nothing was thrown`
assertThrows<Exception> { v5() }
}
We can find the culprit for this in our implementation of the nullable
function. Here it is again:
inline fun<T> nullable(block: NullableScope.() -> T): T? {
return with(NullableScope()) {
try {
block()
} catch (e: Exception) {
null
}
}
}
We catch every possible exception and just return null. We can do better.
private class NullableException: Exception()
class NullableScope {
fun<T> T?.bind(): T {
return this ?: throw NullableException()
}
}
inline fun<T> nullable(block: NullableScope.() -> T): T? {
return with(NullableScope()) {
try {
block()
} catch (e: NullableException) {
null
}
}
}
Furthermore, we introduce a private Exception class NullableException
, made private, so it cannot be used nor extended beyond the scope of this module. Then replace the Exception
we threw inside bind
and the one we caught inside the nullable
function body, with it. This way, the nullable
block can only return null
if NullableException
is thrown, therefore every other exception can be thrown successfully. And our test passes now 🔥
@Test
fun `nullable scope does lets exception to be thrown`() {
val v1: String? = null
val v2: Int? = null
var count = 0
val v5 = {
nullable {
if (count == 0) {
throw Exception()
}
val v3 = v1.bind()
val v4 = v2.bind()
v3.slice(0..v4)
}
}
assertEquals(count, 0)
// passes ✅
assertThrows<Exception> { v5() }
}
Let's try to use this our new invention with the person creation problem
fun createPerson3() {
val person: Person? = nullable {
val name = personService.getName().bind()
val age = personService.getAge().bind()
val email = personService.getEmail().bind()
personService.createPerson(name, age, email)
}
}
Speaking of breath of fresh air 😌. Here, we have avoided the need for nesting, and that of multiple chaining. It just reads like plain ol' imperative code.
Let's go a bit above and beyond by reimplementing the zip
function with the nullable
block.
fun<T, U> T?.zip(other: U?): Pair<T, U>? = nullable {
Pair(this@zip.bind(), other.bind())
}
This pattern is widespread in a lot of other programming languages, like Haskell's do block, Rust's ?
operator, Scala's for
block and a lot more. Arrow-kt's raise.nullable provides this functionality, so if you have arrow-kt as a dependency, you can use that. If not, you now know how to implement one by yourself. Et voilà!
Code used for this post can be found on GitHub here
Going through the code on github, you would notice the use of @sample block in the kdoc documentation comments. As someone very passsionate about good and tested documentations, I encourage everyone to use this in their documentation comments. It makes it easy for anyone using your functions to easily glance over how to use them without having to dive into your source code. Simply put, documentation with just explanation text is not enough, add useful examples as well.
I have a couple of reviews from Reddit and other places, that I would be addressing below, later in the day. Thanks for reading and your comments