Totally not subjective guide to Kotlin’s let/apply/also/with/run

Jerzy Chałupski
3 min readOct 7, 2019

--

Kotlin’s stdlib contains a ton of helper methods that make our lives easier and our code more readable. Right next to all the goodies there’s a file with a couple methods which raised a ton of controversy in Kotlin and Android communities. I’m talking about methods like let, apply, also, etc.

The thing is, these methods are not very useful on their own. They don’t have a single, well-defined responsibility like List.isEmpty. Instead, they are a glue code that binds other functionality together. Because their purpose is so vague, people use them in dozens of different ways, often using the different methods for the same use case in different parts of the codebase.

My problem with that approach is that it hurts code readability. As a species, we’re great as pattern matching, not as great at picking up minute details (unless we’re talking about spotting the tiger in the grass; that one we’re allegedly pretty good at). Using common patterns for these ambiguous stdlib methods leverage the pattern matching abilities; using these methods in an ad-hoc manner requires reading each case in separation. Applying these common patterns also frees you up from deciding which one to use in a particular context. In a weird sense, you’re getting more freedom through more discipline.

Without further ado, here’s how I use The Nebulous Five from Kotlin’s stdlib.

let

On nullable objects that one’s a Kotlin’s version of Java 8 Optional.map

// Java 8
Optional
.ofNullable(someCall())
.map(foo -> foo.anotherCall())
// Kotlin
someCall()?.let { it.anotherCall() }

On a non-nullable it can be used like a pipeline operator from Elixir, pushing the data through the series of transformations.

data
.let { someTransform(it) }
.let { anotherTransform(it) }
.let { yetAnotherTransform(it) }

apply

Primary use case is an interop with setters which doesn’t return the object itself from setter methods or similar state mutation:

val args = Bundle().apply {
putString("key", "value")
putBoolean("flag", true)
}

also

I use it for adding a side effect on the returned object, like adding a logging call or pushing the object into Rx Subject

getErrorMessage(result)
.also { logger.log("Error: $it") } // side effect
.run { view?.showError(this) }

with

One use case is extending local call context to reduce the boilerplate:

// instead of…
someCall(result.foo, result.bar, result.baz)
// …you can have
with(result) { someCall(foo, bar, baz) }

Another one, more exotic use case, is pulling in extension methods into scope:

// extension method is scoped to the object to prevent global namespace pollution
object FancyShmancyTextRendering {
fun TextView.setTextColor(foo: Foo,
colorResolver: ColorResolver) {
setTextColor(foo.toColor(colorResolver))
}
}
// pulling in the extension as needed
with(FancyShmancyTextRendering) {
someView.setTextColor(myDomain.foo, colorResolver)
}

run

One pattern is using it as the with method for nullable properties:

result?.run { someCall(foo, bar, baz) }

Another one is invoking an action on an object created by the chain of other methods:

result
.let { mapper.map(it) }
.also { logger.log(it) }
.run { runner.run(it) }

“Yeah, well, you know, that’s just, like, your opinion, man.”

Sure it is! Feel free to ignore it!

Or, maybe, try it out and see if a bit more disciplined approach works for you.

--

--