Kotlin / Reusable behavior and typed boundaries / Lesson 10
Use extensions and scope functions with clear receivers
Understand static extension resolution and the receiver/result differences among let, run, with, apply, and also.
On this page
Lesson goals
- Predict extension selection from the declared receiver type.
- Choose a scope function by its return value and readable context.
An extension adds callable syntax rather than a class member
An extension function lets a caller use receiver-style syntax without modifying the receiver class. It cannot access private implementation merely because it looks like a member. Extension resolution is based on the declared receiver type at the call site, not virtual dispatch on the object’s runtime subtype. A real member with a matching applicable signature takes precedence over an extension. Keep extensions small and discoverable, and avoid names that suggest a stronger mutation, ownership, or performance guarantee than the implementation supplies. They are ordinary functions with a convenient calling form.
Separate extension selection from overriding
The example has an open class hierarchy but two independent extension functions. The variable is declared Base even though it holds a Derived instance, so the Base extension is selected. If behavior must vary polymorphically, define an overridable member or an interface operation instead of relying on an extension. This distinction is especially important in generic utilities where the declared receiver may be a broad interface. An apparently helpful extension can quietly change behavior when an import or a new member is introduced, so public API naming and review still matter.
open class Base
class Derived : Base()
fun Base.label(): String = "base extension"
fun Derived.label(): String = "derived extension"
fun main() {
val value: Base = Derived()
println(value.label())
println(Derived().label())
}
base extension
derived extension
Expected output is base extension followed by derived extension. The runtime object alone does not determine the first call. This is complete illustrative source and was not compiled here.
Read both the receiver form and the returned value
let and also expose the context as an argument, commonly it, while run, with, and apply expose it as a receiver, commonly this. let, run, and with return the block’s result; apply and also return the context object. These differences should guide selection. apply often suits configuring an object, also suits an additional observation, and let can transform a value. These are conventions rather than enforcement. A scope function does not validate nullability by itself; a safe call such as value?.let controls whether its block runs for a nullable receiver.
Do not accidentally replace an object with a block result
The builder example uses apply to retain the StringBuilder while appending text, then let to compute its length. Switching apply to run without checking the last expression can change the inferred return type. A chain should still reveal which value moves to the next step. When a chain needs nested this and it references, introduce a named local or helper function instead of making a reader reconstruct several implicit receivers. Readability is an API property, and saving a few variable names is not a sufficient reason to obscure the state being modified.
fun main() {
val builder = StringBuilder().apply {
append("seat")
append("-1")
}
val length = builder.let { text -> text.length }
val observed = builder.also { text -> println(text.toString()) }
println(length)
println(observed === builder)
}
seat-1
6
true
Expected output is seat-1, 6, true. also prints the text and returns the same builder reference. let returns the integer length. No Kotlin execution was performed.
Nullable extensions can make absence handling explicit
An extension may declare a nullable receiver and handle null inside its body, so calling it does not necessarily require a safe call. This can be useful for a clearly named presentation helper, but it can also hide the difference between missing and empty data if it returns the same text for both. Keep domain validation separate from convenient display defaults. Extensions should not smuggle global state or surprising I/O into operations that look like harmless value transformations. Document any side effect and prefer an explicit service call when the operation depends on an external resource.
Common errors and fixes
The Base extension runs for a Derived object unexpectedly.
- Why it happens
- Extension resolution used the declared receiver type.
- How to fix it
- Use a polymorphic member when runtime subtype behavior is required.
- Prevent it next time
- Check receiver types in generic and interface-based callers.
A scope chain returns Unit where an object was expected.
- Why it happens
- A result-returning function used a last expression without useful value.
- How to fix it
- Choose apply or also to retain the object, or return the intended value explicitly.
- Prevent it next time
- Read the inferred result at each chain boundary.
Nested scopes modify the wrong receiver.
- Why it happens
- Implicit this and it references became ambiguous to the reader.
- How to fix it
- Name receivers or extract a helper with explicit parameters.
- Prevent it next time
- Avoid nesting scope functions when local names explain the flow better.
Check your understanding
1. Which scope function would you use to configure an object and return that same object?
Show worked solution
apply is a conventional choice when receiver-style configuration is clear; also can retain the object while using an explicit parameter. Check the code’s readability rather than applying a rule by habit. run and let return the block result, so they need deliberate last-expression reasoning if used for this task.
2. Can adding an extension override an existing class member?
Show worked solution
No. A matching applicable member wins over the extension. Extensions do not modify the class’s virtual method table. If behavior must be replaced, use a supported override, adapter, or composition boundary and review its contract. An import change cannot turn an extension into a true member override.
Key takeaways
- Extensions use static receiver resolution and ordinary function boundaries.
- Choose scope functions by receiver clarity and returned value.