Scope Functions #
There are five functions in the Kotlin Standard Library that look similar but play different roles: let, run, with, apply, and also. All five are called scope functions because they open a new code block with a specific object context — you can access that object inside the block without naming it repeatedly. Scope functions are among the most abused yet most useful features in Kotlin. Used correctly, they eliminate temporary variables, clarify code intent, and make operation chains feel natural. Used carelessly, they create code that’s hard to read and debug. This article covers when and how to use each one properly.
The Scope Functions Map #
Before diving into each one, it’s important to understand the two dimensions that distinguish them: how the object is accessed inside the block, and what the function returns.
flowchart TD
A["Scope Function"] --> B["Object reference\ninside the block?"]
B --> C["it — as a lambda parameter\nlet, also"]
B --> D["this — as a receiver\nrun, with, apply"]
A --> E["Returned value?"]
E --> F["The lambda result\nlet, run, with"]
E --> G["The object itself\napply, also"]| Function | Object reference | Return value | Called on |
|---|---|---|---|
let | it | The lambda result | Object (extension) |
run | this | The lambda result | Object (extension) |
with | this | The lambda result | Object (not an extension) |
apply | this | The object itself | Object (extension) |
also | it | The object itself | Object (extension) |
Two questions that always help you choose:
- Do you need the result, or the object?
- Do you need to reference another object inside the block?
let — Transformation with Null Safety #
let calls the block with the object as it and returns the lambda result. This makes it the primary choice for two scenarios: value transformation and conditional execution on nullable objects.
Null Safety with let #
data class User(val nama: String, val email: String?)
val user: User? = dapatkanUser()
// ANTI-PATTERN: verbose manual null checking
if (user != null) {
val email = user.email
if (email != null) {
kirimEmail(email)
}
}
// CORRECT: chained let with safe calls
user?.let { u ->
u.email?.let { email ->
kirimEmail(email)
}
}
// Or more concise if only one level
user?.email?.let { kirimEmail(it) }
?.let is the idiomatic Kotlin pattern for “do something if not null”. The block inside let only executes if the object isn’t null — and inside the block, it is guaranteed non-null so no more ?. is needed.
Value Transformation #
// let for transformation: change one type into another
val panjangNama: Int? = user?.nama?.let { nama ->
nama.trim().length
}
// Useful for transforming the result of one operation before use
val hasil = ambilDataMentah()
.let { raw -> parseJson(raw) }
.let { parsed -> validasi(parsed) }
.let { valid -> simpanKeDisk(valid) }
// Limiting the scope of temporary variables
val pesanFormatted = buildString {
val timestamp = System.currentTimeMillis() // timestamp only used here
val prefix = "[${formatWaktu(timestamp)}]"
append("$prefix Process complete")
}.let { pesan ->
if (pesan.length > 100) pesan.substring(0, 97) + "..." else pesan
}
Replacing Temporary Variables #
// ANTI-PATTERN: temporary variables used only once
val input = editText.text.toString()
val inputBersih = input.trim()
val inputValid = if (inputBersih.isEmpty()) null else inputBersih
proses(inputValid)
// CORRECT: let removes the temporary variables
editText.text.toString()
.trim()
.let { if (it.isEmpty()) null else it }
?.let { proses(it) }
Use explicit parameter names (u,item) instead ofitwhen theletblock is more than one line or when there are nestedletblocks — this prevents confusion about whichitis being referenced.
apply — Object Configuration #
apply calls the block with the object as this and returns the object itself. This makes it the perfect choice for configuring objects after creation — you set properties, call methods, and the configured object is returned directly.
// ANTI-PATTERN: manual configuration with temporary variables
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra("id", produkId)
intent.putExtra("nama", produkNama)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
// CORRECT: apply for clean configuration
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("id", produkId)
putExtra("nama", produkNama)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
apply is very common when working with the builder pattern or objects that need many properties set before use.
// Configuring an OkHttpClient
val client = OkHttpClient.Builder().apply {
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(30, TimeUnit.SECONDS)
addInterceptor(loggingInterceptor)
addInterceptor(authInterceptor)
if (BuildConfig.DEBUG) {
addNetworkInterceptor(chuckerInterceptor)
}
}.build()
// Configuring a data object
data class Konfigurasi(
var host: String = "localhost",
var port: Int = 8080,
var timeout: Int = 30,
var maxRetries: Int = 3,
var enableSsl: Boolean = false
)
val config = Konfigurasi().apply {
host = "api.example.com"
port = 443
timeout = 60
enableSsl = true
}
// Initializing a View in Android
val textView = TextView(context).apply {
text = "Hello, World!"
textSize = 16f
setTextColor(Color.BLACK)
setPadding(16, 8, 16, 8)
gravity = Gravity.CENTER
}
Because apply returns the object itself, it can be directly chained with subsequent operations:
val pesanan = Pesanan()
.apply { tambahItem(laptop) }
.apply { tambahItem(mouse) }
.apply { setAlamat(alamatPengiriman) }
.apply { terapkanDiskon(kodePromo) }
also — Side Effects Without Changing the Flow #
also calls the block with the object as it and returns the object itself — the same as apply regarding the return value, but different in how the object is referenced. Because it uses it, also doesn’t obscure the this of the outer scope, making it ideal for side effects like logging, validation, or debugging.
// Logging in the middle of a chain without breaking the flow
val hasil = repository.ambilData()
.also { data -> log.debug("Data received: ${data.size} items") }
.filter { it.aktif }
.also { filtered -> log.debug("After filtering: ${filtered.size} items") }
.sortedBy { it.nama }
// Validating along the way
fun simpanUser(user: User): User {
return user
.also { require(it.nama.isNotBlank()) { "Name must not be empty" } }
.also { require(it.email.contains("@")) { "Invalid email" } }
.also { userRepository.save(it) }
}
// Debugging: see the value in the middle of a chain without changing it
val total = daftarHarga
.filter { it > 0 }
.also { println("Valid prices: $it") } // print for debugging
.sum()
.also { println("Total: $it") }
apply vs also Differences #
class Laporan {
var judul = ""
var konten = ""
fun generate() = "$judul\n$konten"
}
// apply: this = object, suitable for setting properties
val laporan = Laporan().apply {
judul = "Monthly Report" // this.judul = ...
konten = "Report content..." // this.konten = ...
}
// also: it = object, suitable for side effects
val laporanDenganLog = Laporan().apply {
judul = "Monthly Report"
konten = "Report content..."
}.also {
println("Report created: ${it.judul}") // it refers to the report
auditLog.catat("New report: ${it.judul}")
}
run — A Code Block with a Result #
run comes in two forms: as an extension function (called on an object) and as a regular function (without a receiver). Both execute the block and return the lambda result.
run as an Extension Function #
// run: this = object, returns the lambda result
// Suitable when you need the result of a computation from the object
data class Koneksi(val host: String, val port: Int, val ssl: Boolean)
val urlKoneksi = Koneksi("api.example.com", 443, true).run {
val protokol = if (ssl) "https" else "http"
"$protokol://$host:$port" // this value is returned
}
// "https://api.example.com:443"
// Replacing temporary variables for complex computations
val pesanStatus = koneksi.run {
val status = if (ssl) "secure" else "insecure"
val info = "$host:$port ($status)"
if (aktif) "Connected to $info" else "Disconnected from $info"
}
run as a Regular Function #
run { } without a receiver is useful for grouping code blocks that produce a value, or for giving scope to temporary variables.
// ANTI-PATTERN: temporary variables polluting the outer scope
val a = hitungA()
val b = hitungB(a)
val c = hitungC(a, b)
val hasil = a + b + c
// a, b, c are still accessible outside, even though they're only needed for the result
// CORRECT: run limits the scope of intermediate variables
val hasil = run {
val a = hitungA()
val b = hitungB(a)
val c = hitungC(a, b)
a + b + c // this is returned to 'hasil'
}
// a, b, c are not accessible here
// Complex initialization
val koneksiDb = run {
val driver = loadDriver(config.driverClass)
val props = Properties().apply {
setProperty("user", config.username)
setProperty("password", config.password)
setProperty("ssl", config.ssl.toString())
}
driver.connect(config.url, props)
}
with — Operations on an Existing Object #
with is the only scope function that isn’t an extension function — the object is passed as an argument, not called with a dot. with executes the block with the object as this and returns the lambda result.
// Syntax: with(objek) { ... }
data class Laporan(val judul: String, val data: List<String>, val penulis: String)
val laporan = Laporan("Q1 Report", listOf("Item A", "Item B", "Item C"), "Andi")
// with for many operations on an existing object
val output = with(laporan) {
buildString {
appendLine("=== $judul ===")
appendLine("Author: $penulis")
appendLine("Contents:")
data.forEachIndexed { i, item ->
appendLine(" ${i + 1}. $item")
}
}
}
// with for accessing many properties without repeating the object name
with(konfigurasi) {
println("Host: $host")
println("Port: $port")
println("SSL: $enableSsl")
println("Timeout: ${timeout}s")
}
with vs run #
Both use this and return the lambda result. The main difference is syntax and usage nuance:
val objek = DapatkanObjek()
// run: called on the object (extension), more natural for chains
val hasil1 = objek.run {
// this = objek
lakukan()
}
// with: object as an argument, more expressive for "do many things on X"
val hasil2 = with(objek) {
// this = objek
lakukan()
}
// with suits when the object variable name is long or doesn't need chaining
with(binding.recyclerViewProduk) {
layoutManager = LinearLayoutManager(context)
adapter = produkAdapter
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
setHasFixedSize(true)
}
Choosing the Right Scope Function #
This is a decision tree to use whenever you’re confused about which to choose:
flowchart TD
A{What do you\nwant to do?} --> B["Configure an object\n(set properties, call methods)"]
A --> C["Transform or\ncompute from an object"]
A --> D["Side effects\n(logging, validation)"]
A --> E["Null safety /\nexecute if not null"]
A --> F["Many operations\non an existing object"]
B --> G["apply\n(this, returns the object)"]
C --> H{Need to return\nthe object?}
H -- Yes --> I["Nothing fits\n→ use run + also keep the reference"]
H -- No --> J["run or with\n(this, returns the lambda result)"]
D --> K["also\n(it, returns the object)"]
E --> L["let with ?.\n(it, returns the lambda result)"]
F --> M["with\n(this, returns the lambda result)"]An easy-to-remember practical summary:
Configuring a new object? → apply (this, returns the object)
Side effects / logging? → also (it, returns the object)
Transformation / computation? → let (it, returns the result)
Computation with full access? → run (this, returns the result)
Many operations on an existing object? → with (this, returns the result)
Null safety? → ?.let (it, returns the result)
Anti-Patterns to Avoid #
1. Overly Deep Nesting of Scope Functions #
// ANTI-PATTERN: three levels of nesting, hard to read
val hasil = objekA.let { a ->
objekB.apply {
nilai = a.run {
hitungNilai()
}
}
}
// CORRECT: split into separate variables or functions
val nilaiA = objekA.hitungNilai()
val hasil = objekB.apply {
nilai = nilaiA
}
2. Using Scope Functions Just to Look Cool #
// ANTI-PATTERN: apply adds no value here
val angka = 42.apply { println(this) }
// CORRECT: just write it directly
val angka = 42
println(angka)
// ANTI-PATTERN: let is unnecessary
val panjang = nama.let { it.length }
// CORRECT: access directly
val panjang = nama.length
3. Obscuring the Return Value #
// ANTI-PATTERN: it's unclear what apply returns
fun buatUser(): User {
return User("Andi").apply {
email = "[email protected]"
// apply returns the User — this is actually correct,
// but new developers might think it returns Unit
}
}
// CORRECT: explicit
fun buatUser(): User {
val user = User("Andi").apply {
email = "[email protected]"
}
return user
// or: return User("Andi").also { it.email = "[email protected]" }
}
4. Using it When the Context Is Unclear #
// ANTI-PATTERN: it in a long block — what does it refer to?
daftarProduk.firstOrNull { it.aktif }?.let {
tampilkan(it.nama)
hitung(it.harga)
if (it.stok > 0) {
tambahKeKeranjang(it)
}
}
// CORRECT: give an explicit name
daftarProduk.firstOrNull { it.aktif }?.let { produk ->
tampilkan(produk.nama)
hitung(produk.harga)
if (produk.stok > 0) {
tambahKeKeranjang(produk)
}
}
Idiomatic Patterns in Production Code #
Some scope function combinations that frequently appear in real Kotlin code.
apply + also for Configuration and Logging #
val httpClient = OkHttpClient.Builder()
.apply {
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(30, TimeUnit.SECONDS)
addInterceptor(authInterceptor)
}
.build()
.also {
log.info("HTTP client created with a 30s timeout")
}
let for Transformation Pipelines #
// Pipeline: take → clean → validate → save
fun prosesInput(raw: String?): Result<String> {
return raw
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { input ->
if (input.length > 255) input.substring(0, 255) else input
}
?.let { input -> Result.success(input) }
?: Result.failure(IllegalArgumentException("Input must not be empty"))
}
run for Complex Initialization #
// Initialization that needs many steps
val repositori: UserRepository = run {
val dataSource = HikariDataSource().apply {
jdbcUrl = config.dbUrl
username = config.dbUser
password = config.dbPassword
maximumPoolSize = 10
}
val mapper = ObjectMapper().apply {
registerModule(KotlinModule.Builder().build())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
UserRepositoryImpl(dataSource, mapper)
}
with for View Configuration (Android) #
// A common pattern in Android for RecyclerView configuration
with(binding) {
recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = itemAdapter
addItemDecoration(dividerDecoration)
}
searchInput.addTextChangedListener { text ->
viewModel.cariItem(text.toString())
}
fabTambah.setOnClickListener {
navigasiKeTambahItem()
}
}
also for Sequential Validation #
fun validasiDanSimpanPesanan(pesanan: Pesanan): Pesanan {
return pesanan
.also { require(it.items.isNotEmpty()) { "Order must have at least 1 item" } }
.also { require(it.totalHarga > 0) { "Total price must be greater than 0" } }
.also { require(it.alamatPengiriman.isNotBlank()) { "Shipping address is required" } }
.also { pesananRepository.simpan(it) }
.also { notifikasiService.kirim(it.userId, "Order #${it.id} created successfully") }
}
When Not to Use Scope Functions #
Scope functions aren’t the solution for every situation. There are conditions where code is clearer without them.
Keep using scope functions if:
✓ You need null safety with ?.let
✓ Configuring an object with many properties (apply)
✓ Logging/debugging in the middle of a chain (also)
✓ Limiting the scope of temporary variables (run)
✓ Many operations on one object (with)
Avoid scope functions if:
✗ The block is one line that can be written directly
✗ They produce nesting deeper than two levels
✗ The purpose is only to make code look "more Kotlin"
✗ The team isn't familiar and code becomes harder to read
✗ The return value is unclear or confusing
Summary #
let— references the object asit, returns the lambda result. Best for null safety (?.let) and value transformation. Use explicit names (nama,item) instead ofitif the block is more than one line.apply— references the object asthis, returns the object itself. The primary choice for object configuration: set properties, call setup methods, then the object is ready to use.also— references the object asit, returns the object itself. Perfect for side effects like logging, validation, or debugging without obscuringthisfrom the outer scope.run— references the object asthis, returns the lambda result. Suitable for computations needing full access to the object, or as a regular functionrun { }to limit the scope of temporary variables.with— not an extension function, the object as an argument,thisinside the block. Most natural for many operations on an existing object, especially when the object name is long.- Two guiding questions: (1) do you need the result or the object? →
let/run/withvsapply/also. (2) do you need to reference another object inside the block? → useit(let,also) rather thanthis(run,apply,with).- Main anti-patterns: overly deep nesting, using
itwithout an explicit name in long blocks, and using scope functions only to look idiomatic without real benefit.- The best scope function is the one that makes code easier to read, not shorter. If the code is clearer without a scope function, don’t use one.