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

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 the first and second getters, but we quickly get to the point where we need to do pair.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