Kotlin Nullables: How to Compose Them Right

Written by Pcc | Published 2020/12/19
Tech Story Tags: kotlin | nullable-value-types | nullable | monads | konad | functional-programming | functional-design | hackernoon-top-story | web-monetization

TLDRvia the TL;DR App

How to compose independently computed Kotlin nullables, in an easy and clean way

In Kotlin we have null safety
Despite nullable can’t be totally considered as a monad, there are many advantages to using it. Your API can express optionality in a clean/compile-time-safe way, and bring back your side-effectful function to the pure world.
I find this feature as one of the most exciting and idiomatic of this language. Anyway, there is one drawback. Composability!  
I will propose a solution to this annoyance.

Have you ever tried to compose nullables?

Well… I can’t say that composing independently computed values really shines. Let’s figure out a real-world use-case.
Crypto exchange
Let’s think about a crypto exchange. Suppose that when registering, they ask you for your name, a username, and an email. Once registered you can start crawling the crypto world.
You get excited about this world, and you want to start buying crypto. You can’t! 
Before you have to fill in your phone number, your credit card and complete the KYC. All these are optional info, related to the user.
Let’s model it in our business logic:
data class CryptoUser(
  val username: String, 
  val name: PersonalName, 
  val email: Email,
  val phoneNumber: PhoneNumber? = null,
  val creditCard: CreditCard? = null,
  val kycVerification: KycVerificationData? = null)
The exchange has the
becomeRich
procedure that, in order to let you buy crypto, requires those optional data.
data class BuyCryptoInfo(
  val username: String,
  val phoneNumber: PhoneNumber,
  val creditCard: CreditCard,
  val kycVerification: KycVerificationData)

fun becomeRich(crypto: CryptoInfo, buyInfo: BuyCryptoInfo) = TODO("conquer the world")

Non-null from nullables

Can you figure out a way to go from
CryptoUser
to
BuyCryptoInfo
?
You have two options, proposed as the following factory methods:
data class BuyCryptoInfo(
  val username: String,
  val phoneNumber: PhoneNumber,
  val creditCard: CreditCard,
  val kycVerification: KycVerificationData){

  companion object{
      fun from1(user: CryptoUser): BuyCryptoInfo? = with(user){
          if(phoneNumber != null && creditCard != null && kycVerification != null) 
              BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification)
              else null
      }
    
      fun from2(user: CryptoUser): BuyCryptoInfo? = with(user){
          phoneNumber?.let {
          creditCard?.let {
          kycVerification?.let { 
              BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification) 
          }}}
      }
  }  
}
Personally, I don’t like any of those solutions. But let’s continue.
You click on the “buy” button, and suddenly you get…
Something mandatory is missing, please check your info
With
nulls
, we are only telling that something is missing but not what. Let’s change the code!
sealed class Result<out T>{
    data class Ok<out T>(val value: T): Result<T>()
    data class Error(val description: String): Result<Nothing>()
}

data class BuyCryptoInfo(
  val username: String,
  val phoneNumber: PhoneNumber,
  val creditCard: CreditCard,
  val kycVerification: KycVerificationData){

  companion object{
      fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){
          if(phoneNumber == null) return Result.Error("missing phone number")
          if(creditCard == null) return Result.Error("missing credit card")
          if(kycVerification == null) return Result.Error("missing kyc verification")
          
          return Result.Ok(BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification))
      }
  }  
}
Perfect now we handle the errors! 
Let’s try again to buy! Click…
missing phone
. Fill in the phone… Buy!
missing credit card
. Fill in the credit card… Buy!
missing kyc verification
Let’s change the code again…
data class BuyCryptoInfo(
  val username: String,
  val phoneNumber: PhoneNumber,
  val creditCard: CreditCard,
  val kycVerification: KycVerificationData){

  companion object{
      fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){
            val listOfErrors = mutableListOf<String>()

            if(phoneNumber == null) listOfErrors.add("missing phone number")
            if(creditCard == null) listOfErrors.add("missing credit card")
            if(kycVerification == null) listOfErrors.add("missing kyc verification")

            if(listOfErrors.size > 0) return Result.Error(listOfErrors.joinToString(","))

            return Result.Ok(BuyCryptoInfo(username, phoneNumber!!, creditCard!!, kycVerification!!))
      }
  }
}
Now the user gets all the errors at once. And he is satisfied… but what about you, as a developer? Are you satisfied as well?

Konad to the rescue!

I was not happy at all with solutions like the one above. Then I started investigating for a better way.
That was a long trip. I went through Monads, Applicative Functors, and Higher-Kinded types. 
In the end, I realized that what I was searching for didn’t exist yet for Kotlin. We have Arrow, but I think that it is an overkill for this specific purpose. And also, it has a steep learning curve for an OOP developer.
Then I decided to build my own solution and finally, I came out with Konad.
With Konad, the methods above become as follows:
import io.konad.*

data class BuyCryptoInfo(
  val username: String,
  val phoneNumber: PhoneNumber,
  val creditCard: CreditCard,
  val kycVerification: KycVerificationData){

  companion object{
      // You if you want to go for errors
      fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){
            ::BuyCryptoInfo.curry()
                .on(username)
                .on(phoneNumber.ifNull("missing phone number"))
                .on(creditCard.ifNull("missing credit card"))
                .on(kycVerification.ifNull("missing kyc verification"))
                .result
        }

        // You if you want to go just for a null
        fun from2(user: CryptoUser): BuyCryptoInfo? = with(user){
            ::BuyCryptoInfo.curry()
                .on(username)
                .on(phoneNumber.maybe)
                .on(creditCard.maybe)
                .on(kycVerification.maybe)
                .nullable
        }
  }
}
Hopefully, this can be considered pretty much cleaner than the previous proposals.
Let’s finally see how to become rich!
fun becomeRich(crypto: CryptoInfo, buyInfo: BuyCryptoInfo): Boolean = false

val user = CryptoUser(username = "foo.bar", ..., ...)
val crypto = CryptoInfo("Bitcoin")

val youGotRich1: Result<Boolean> = ::becomeRich.curry()
  .on(crypto)
  .on(BuyCryptoInfo.from1(user))
  .result

// or 

val youGotRich2: Result<Boolean> = BuyCryptoInfo.from1(user)
  .map { cryptoInfo -> becomeRich(crypto, cryptoInfo) }

when(youGotRich1 /*or youGotRich2*/){
  is Result.Ok -> "Congrats!"
  is Result.Errors -> youGotRich1.description
}.run(::println) // Will print Congrats! or the list of the missing information

Conclusion

Kotlin nullables are perfect to express the optionality of a computation. Anyway, they lack some UX in case of some more advanced use-cases.
My hope is to have filled that gap with Konad, and that you will enjoy using it.
Any feedback is much appreciated, as well as contributions to the library and bug or conceptual mistakes reporting. You can contact me on Twitter at @luca_picci or commenting the article.
Thank you for reading.

Written by Pcc | I am a programmer. I love programming. Any language, any paradigm
Published by HackerNoon on 2020/12/19