Work with Scope Functions
Problem
Object configuration and null-safe operations often lead to verbose code with repeated variable names and temporary variables. Kotlin’s scope functions (let, run, with, apply, also) provide concise patterns for operating on objects within a specific scope.
This guide shows how to choose and use the right scope function for each situation.
Scope Function Overview
The Five Scope Functions
Each scope function has specific use cases based on context object access and return value.
// Context = this, Returns = context
val person = Person().apply {
name = "Alice"
age = 30
}
// Context = this, Returns = lambda result
val greeting = person.run {
"Hello, $name (age $age)"
}
// Context = it, Returns = lambda result
val name = person.let {
it.name.uppercase()
}
// Context = it, Returns = context
val samePerson = person.also {
println("Created: ${it.name}")
}
// Non-extension, Context = this, Returns = lambda result
val result = with(person) {
"$name is $age years old"
}Quick Reference:
| Function | Context | Returns | Common Use |
|---|---|---|---|
let | it | Lambda result | Null-safe calls, transformations |
run | this | Lambda result | Object configuration + computation |
with | this | Lambda result | Grouping calls on object |
apply | this | Context object | Object configuration |
also | it | Context object | Additional operations (logging) |
let Function
Null-Safe Operations
Use let with safe call for null-safe execution.
// ✅ Execute block only if not null
val name: String? = getUsername()
name?.let {
println("Hello, $it")
database.updateLastSeen(it)
}
// ✅ Transform nullable value
val length: Int? = text?.let { it.trim().length }
// ✅ Avoid nested null checks
user?.profile?.address?.let { address ->
println("${address.city}, ${address.country}")
}Pattern: nullable?.let { ... } - safe execution with clear scope.
Introducing Local Variables
Use let to limit variable scope.
// ✅ Transform and use
val result = fetchData().let { data ->
process(data)
validate(data)
data.transform()
}
// ✅ Avoid polluting outer scope
fun processConfig() {
loadConfig().let { config ->
// config only exists in this scope
applySettings(config)
saveToCache(config)
}
// config not accessible here
}Use case: Temporary transformations without polluting namespace.
Chaining Operations
Chain let for functional pipelines.
// ✅ Functional transformation pipeline
val result = getInput()
?.let { it.trim() }
?.let { it.uppercase() }
?.let { it.split(" ") }
?.let { it.filter { word -> word.length > 3 } }
// ✅ With explicit parameters
val formatted = data
.let { rawData -> validate(rawData) }
.let { validData -> transform(validData) }
.let { transformed -> format(transformed) }run Function
Object Configuration with Result
Use run when you need to configure and return a computed value.
// ✅ Configure object and compute result
val message = Person().run {
name = "Alice"
age = 30
email = "alice@example.com"
"Created user: $name" // Return value
}
println(message) // "Created user: Alice"
// ✅ Complex initialization
val config = Configuration().run {
host = "localhost"
port = 8080
timeout = 30
validate() // Returns Boolean
}Pattern: Configure with this, return transformation result.
Null-Safe run
Use run with safe call for null-safe operations.
// ✅ Null-safe run
val result: String? = user?.run {
"$name (${email})"
}
// ✅ Execute multiple operations
connection?.run {
open()
execute("SELECT * FROM users")
close()
}run for Expression Blocks
Use run to create scoped expressions.
// ✅ Multi-line expression
val greeting = run {
val hour = LocalDateTime.now().hour
when (hour) {
in 0..11 -> "Good morning"
in 12..17 -> "Good afternoon"
else -> "Good evening"
}
}
// ✅ Complex condition
val isValid = run {
val emailValid = email.contains("@")
val nameValid = name.length > 2
emailValid && nameValid
}with Function
Operating on Non-Null Objects
Use with for multiple operations on an object.
// ✅ Group operations on object
val person = Person("Alice", 30)
val description = with(person) {
println("Name: $name")
println("Age: $age")
"Person: $name" // Return value
}
// ✅ Configure with builder pattern
val request = with(HttpRequest()) {
setUrl("https://api.example.com")
setMethod("POST")
addHeader("Content-Type", "application/json")
build()
}Pattern: Non-extension function - pass object as parameter.
with for String Building
// ✅ Build complex strings
val html = with(StringBuilder()) {
append("<html>")
append("<body>")
append("<h1>Hello</h1>")
append("</body>")
append("</html>")
toString()
}Use case: When you have an object and need multiple operations returning a result.
apply Function
Object Configuration
Use apply for configuring objects (returns the object itself).
// ✅ Configure object (returns object)
val person = Person().apply {
name = "Alice"
age = 30
email = "alice@example.com"
}
// person is the configured Person instance
// ✅ Builder pattern
val dialog = AlertDialog(context).apply {
setTitle("Confirm")
setMessage("Are you sure?")
setPositiveButton("Yes") { _, _ -> }
setNegativeButton("No") { _, _ -> }
}
// ✅ List configuration
val list = mutableListOf<String>().apply {
add("Item 1")
add("Item 2")
add("Item 3")
}Pattern: Configure with this, return configured object.
apply for Side Effects
Use apply when you want the object back after operations.
// ✅ Configure and pass forward
fun createUser(name: String): User {
return User().apply {
this.name = name
this.createdAt = System.currentTimeMillis()
this.status = "active"
}
}
// ✅ Chaining with apply
val result = fetchData()
.apply { println("Fetched: $this") }
.apply { validate(this) }
.apply { cache(this) }Use case: Configuration with method chaining.
also Function
Additional Operations
Use also for side effects while keeping the original object.
// ✅ Logging and debugging
val user = createUser("Alice")
.also { println("Created user: ${it.name}") }
.also { logUserCreation(it) }
.also { sendWelcomeEmail(it) }
// ✅ Validation
val data = fetchData()
.also { require(it.isNotEmpty()) { "Data cannot be empty" } }
.also { println("Fetched ${it.size} items") }
// ✅ Caching
val config = loadConfig()
.also { cache.put("config", it) }
.also { logger.info("Config loaded: $it") }Pattern: Side effects with it, return original object.
also vs apply
// ✅ apply - configure object members
val person1 = Person().apply {
name = "Alice" // this.name
age = 30 // this.age
}
// ✅ also - external operations on object
val person2 = Person().also {
database.save(it)
logger.info("Saved: ${it}")
}
// ✅ Combining both
val person3 = Person().apply {
name = "Bob"
age = 25
}.also {
database.save(it)
println("Created and saved: ${it.name}")
}Choosing the Right Scope Function
Decision Tree
// Need to return the object itself?
// ├─ Yes
// │ ├─ Need `this` context? → apply
// │ └─ Need `it` context? → also
// └─ No (return lambda result)
// ├─ Extension function?
// │ ├─ Need `this` context? → run
// │ └─ Need `it` context? → let
// └─ Not extension? → withPractical Examples
// ✅ let - null safety, transformation
val length = text?.let { it.trim().length }
// ✅ run - configure and compute
val valid = form.run {
val emailOk = email.isValidEmail()
val nameOk = name.length > 2
emailOk && nameOk
}
// ✅ with - operate on non-null object
val description = with(person) {
"$name is $age years old"
}
// ✅ apply - configure object
val user = User().apply {
name = "Alice"
email = "alice@example.com"
}
// ✅ also - logging, side effects
val data = fetchData()
.also { println("Fetched ${it.size} items") }Common Pitfalls
Overusing Scope Functions
// ❌ Unnecessary scope function
val result = value.let { it }
// ✅ Direct usage
val result = value
// ❌ Too many nested scope functions
val output = data.let {
it.apply {
field1 = 1
}.run {
process(this)
}
}
// ✅ Simpler alternative
val output = data.apply {
field1 = 1
}
process(output)Why problematic: Reduces readability without adding value.
Mixing this and it Contexts
// ❌ Confusing context mixing
Person().apply {
name = "Alice" // this.name
}.also {
it.age = 30 // it.age (different context!)
}
// ✅ Consistent context
Person().apply {
name = "Alice"
age = 30
}
// ✅ Or explicit parameters
Person().apply {
name = "Alice"
}.also { person ->
person.age = 30
println(person.name)
}Why problematic: Context switching reduces clarity.
Ignoring Return Values
// ❌ Expecting apply to return result
val description = person.apply {
"$name is $age" // Lost! apply returns person, not string
}
// ✅ Use run for result
val description = person.run {
"$name is $age" // Returns string
}Why problematic: Wrong scope function for the task.
Variations
Combining Scope Functions
Chain scope functions for complex operations.
// ✅ Practical combination
val result = fetchUser(userId)
?.let { user -> // Null safety
user.profile?.address
}
?.run { // Transform
"$city, $country"
}
.also { address -> // Log
println("User address: $address")
}
?.let { it.uppercase() } // Final transformation
?: "Unknown location" // DefaultScope Functions with DSLs
// ✅ DSL with apply
fun buildHtml() = StringBuilder().apply {
append("<html>")
apply {
append("<head>")
append("<title>My Page</title>")
append("</head>")
}
apply {
append("<body>")
append("<h1>Hello</h1>")
append("</body>")
}
append("</html>")
}.toString()Related Patterns
Learn more:
- Nullable Types - let with null safety
- Extension Functions - Custom scope-like functions
- Beginner Tutorial - Scope Functions - Scope function fundamentals
- Best Practices - Scope Functions - Idiomatic usage guidelines
Cookbook recipes:
- Builder Pattern with apply - Configuration patterns
- Type-Safe Builders - DSL with scope functions