Let it be ?.

In this post, we will go through one of the scope functions offered by Kotlin, which is let and few misconceptions around its usage.

Note: There is nothing wrong with the Kotlin documentation. It documents well how we should be using let in Kotlin. However, as developers, sometimes we still write code based on our initial knowledge about some language features when we learnt them.

In this post, we will cover how we shouldn't be using let. Don't worry; I've been on the same train where we've used it wrong, as I'll mention below. I realized this recently when I came across this tweet from Zhuinden and reading replies to it. If you go through responses, you'd see some good points discussed how we generally use let incorrectly in a few scenarios where it is not required. I agree with most of the points discussed in that thread and will try to cover them here.

TL;DR: Please avoid using let in the control flow. Use if/else and Kotlin's smart casting.

Let's get started. We will go through both examples of incorrect and correct usage of let. We will first go through incorrect use followed by correct one.

Incorrect use - Using it in Control Flow

Scenario: When your function has a nullable value as an input parameter, you want to show/hide some views in Android based on its nullability.

fun renderVideo(video: Video?) {
    video?.let {
        binding.playerContainer.isVisible = true
        binding.videoMetadata.isVisible = true
        binding.videoDuration.isVisible = true
        binding.videoDuration.text = it.duration
    } ?: run {
        binding.playerContainer.isVisible = false
        binding.videoMetadata.isVisible = false
        binding.videoDuration.isVisible = false
        binding.videoDuration.text = ""
    }
}

There is nothing wrong with this code. It works as intended, right? However, there is one problem with it 😅

let returns the value of the lambda result.

Let's say someone in future adds a method call at the end of the let block like below:

fun renderVideo(video: Video?) {
    video?.let {
        binding.playerContainer.isVisible = true
        binding.videoMetadata.isVisible = true
        binding.videoDuration.isVisible = true
        binding.videoDuration.text = it.duration
        doAction()
    } ?: run {
        binding.playerContainer.isVisible = false
        binding.videoMetadata.isVisible = false
        binding.videoDuration.isVisible = false
        binding.videoDuration.text = ""
    }
}

fun doAction() : String? {
    ...
}

Now, if we call a method named doAction which returns null inside the let block, it will also cause our run block to execute as well. Remember, let returns the value of the lambda result, which would be null in this case.

null ?: run {
    // code inside it runs
}

Ideally, you wouldn't add a method call inside the let block like above. Fair point! But what if someone else maintaining this code in future is unaware of this pitfall and adds it? And, then, loses a decent amount of time debugging why both the let and run blocks are running despite the value of video being non-null 🤬

let and run scope functions weren't created to be used in the control flow like above. A simple if/else is readable and doesn't cause any issues down the line in future as well.

fun renderVideo(video: Video?) {
    if(video != null){
        binding.playerContainer.isVisible = true
        binding.videoMetadata.isVisible = true
        binding.videoDuration.isVisible = true
        binding.videoDuration.text = video.duration
    }
    else{
        binding.playerContainer.isVisible = false
        binding.videoMetadata.isVisible = false
        binding.videoDuration.isVisible = false
        binding.videoDuration.text = ""
    }
}

IMO this is a more readable and straightforward solution, but why do people still tend to always reach out for ?.let{} in such situations? This is because we think that only if we write ?.let{} the code written inside the let block is safely non-null on which the let was called.

That is not the case. We can do a null check on the nullable val type and the Kotlin compiler will always smart-cast it to a non-null type under it. We don't need to reach out to let for it.

fun printInfo(person: Person?){
    if(person != null){
        // `Person` object is now smart casted to non-null type.
        // And, it will be non-null inside this if scope.
        println(person.name)
    }
}

Note: The important thing is that it has to be val and not var because the Kotlin compiler can't do a smart cast in that case. Generally, when we receive these values in our function parameter, they are always val because var as a function parameter is not a thing in Kotlin.

Do we all agree that using old school if/else is much better than being clever using let and run? Cool! 😄

Now that we've talked about where we should avoid using let, let's look at where we should actually be using it.

Correct use - Chaining methods calls

Let's say we want to write code where we need to call some functions conditionally in sequence.

Scenario :

  • Get the currently displayed product
  • If there is a currently displayed product, fetch updated product details using its "id"
  • If updated product details are available, re-render the displayed product with updated product details.

Without let this is how we would generally write it.

fun renderProduct(){
    val currentlyDisplayedProduct = productDrawerViewModel.getCurrentDisplayedProduct()

    if(currentlyDisplayedProduct != null) {
        val updatedProductDetails = viewModel.getUpdatedProductDetails(updatedProductDetails.id)
        if(updatedProductDetails != null) {
            productDrawerViewModel.renderProduct(updatedProductDetails)
        }
    }
}

How about using let.

fun renderProduct(){
    productDrawerViewModel.getCurrentDisplayedProduct()
        ?.let { displayedProduct -> viewModel.getUpdatedProductDetails(displayedProduct.id) }
        ?.let { updatedProductDetails -> productDrawerViewModel.renderProduct(updatedProductDetails) }
}

We can chain method calls using let, similar to how we run map on collections. IMO much more readable than previous nested if checks. We can also use it to chain non-nullable results; it doesn't have to be ?.let always, but you got the point. This was the one use of let I wanted to highlight. I'd recommend checking in Kotlin documentation for the other use cases of let.

To summarize, in the 1st case, we wanted to avoid using let and run like if/else because of the issue discussed. And, in 2nd case, we're using let to chain method calls to avoid nested if conditions.

We've been incorrectly using ?.let{} ?: run{} a lot in our codebase, instead of a simple if/else, and we need to fix it. Brb! fixing our codebase 😜

Thanks for reading! 🙂