Extensions add functions or computed properties to existing types with member-call syntax, without modifying or inheriting from those types.
An extension doesn’t become a real class member; it resolves from the receiver’s compile-time type, and a matching real member always wins.
Treat extensions as scoped declarations, limit their visibility, and test resolution with different static types, null, and member conflicts.
What it is and why it exists
An extension function is declared outside a type
but can be called in the form receiver.function(). The type to the left of the dot in
the declaration is the receiver type, and this in the body is the receiver object for
that call. This syntax puts the operation’s primary object first without requiring access to its source.
Extensions solve API expression and organization problems, not inheritance problems.
Third-party types, standard-library types, and stable domain models can gain operations
needed by callers while their inheritance hierarchies and object layouts remain unchanged.
Collection operations such as map(), filter(), and joinToString() make extensive use of this form.
Kotlin also supports extension properties . They provide property-access syntax for computed values but can’t add storage to the receiver. Property form usually fits a cheap operation that doesn’t throw and stays stable while receiver state is unchanged; use a function when the operation takes arguments, may be expensive, or has clear action semantics.
An extension doesn’t modify a class or bypass encapsulation.
An extension declared outside the type can use only APIs visible from its declaration site; it
can’t read the receiver’s private or protected members. Nor can a receiver subclass override it like a virtual member.
You encounter extensions in domain formatting, collection operations, third-party API adapters, nullable-value normalization, and small DSLs. A member function is usually more accurate when behavior defines a type’s core invariant, requires private state, or must support runtime polymorphism. An extension should express a capability added from the caller’s perspective, not impersonate behavior promised by the type itself.
How it works
The receiver changes call syntax only
fun String.normalized(): String declares a function whose receiver type is String.
Inside its body, unqualified member access and this both refer to the receiver; the caller
writes input.normalized(). This extension receiver
is still an ordinary parameter role and doesn’t inject the declaration into String.
The receiver type may be an interface, a generic type, a function type, or a nullable type.
In fun <T> List<T>.secondOrNull(): T?, the type parameter precedes the function name so
it is available in both the receiver and return types. With a Customer? receiver, this
may also be null inside the body and must be checked like any other nullable value.
Call syntax says nothing about ownership. An extension may mutate an already mutable receiver or return a new value, but it doesn’t copy objects automatically or gain extra permissions. Review side effects from parameters, return values, and mutability exactly as you would for an ordinary function.
Calls resolve at compile time
Extensions use static dispatch . The compiler selects an extension from the expression’s declared type, declarations visible in the current scope, and the argument list. The object’s runtime subtype doesn’t make a call switch automatically to a different extension with a more specific receiver.
Use these boundaries when reasoning about value.render():
| Condition | Result | Review focus |
|---|---|---|
| The declared type has an applicable member | The member wins | A new member can change source resolution after recompilation |
| No applicable member, but an extension is visible | Resolve among applicable extensions | Check imports, the static receiver type, and arguments |
| Only the runtime subtype has another extension | It doesn’t participate dynamically | Use a virtual member or interface when polymorphism is required |
| There is no unique applicable declaration | Compilation fails | Remove ambiguity with a qualified import, alias, or distinct name |
“Members win” applies to applicable calls. When a member and extension share a name but have different parameter lists, the extension can still be selected as an ordinary overload. A name-only search therefore can’t identify the target; compare the receiver type, parameters, and visible scope too.
Static resolution prevents a third-party extension from replacing behavior already owned by a type, but it also creates an evolution risk. If a dependency adds a member compatible with an existing extension, recompiled source selects the member. Public extensions need domain-specific names and behavior tests that run during dependency upgrades.
Extension properties have no backing fields
An extension property must provide accessors because receiver instances have no backing-field
slot reserved for it. A read-only extension has the form val Type.name: Result get() = ....
A mutable extension may declare a getter and setter, but they must read and write existing
receiver state or external storage; they can’t use a field owned by the extension.
That restriction is a useful design signal. If a property must store new per-instance state, change the type that owns the state, use a wrapper, or create an explicit external map with a lifetime policy. Hiding a global map behind an extension property readily introduces leaks, races, and shared state that is hard to observe.
Property access should look cheap and stable. Network requests, disk reads, large allocations, or parsing that may fail shouldn’t masquerade as extension properties; a function name and parameters communicate those costs and failure boundaries more honestly.
Scope determines the candidate set
A top-level extension belongs to its declaring package.
Callers in another package import it, either with an explicit import that narrows the source
or with import package.longName as localName to resolve a name conflict. Star imports widen
the candidate set, so public code benefits from explicit imports for contested names.
Extensions can also be declared in functions or classes. A local extension serves one implementation scope. A member extension declared in a class can be called only when that class’s dispatch receiver is available. Narrowing declaration scope is often safer than adding a generic name to an entire package.
Visibility modifiers constrain the extension declaration itself.
A file-level private extension is visible only in that file, while an internal extension is
visible in the same module. Neither modifier grants the extension access to private implementation
details of its receiver.
Examples
Adding a domain transformation to strings
This first extension converts a page title into a route segment.
It uses only public String operations and returns a new string without changing the receiver.
fun String.toRouteSegment(): String =
trim()
.lowercase()
.split(Regex("\\s+"))
.filter(String::isNotEmpty)
.joinToString("-")
fun main() {
val topicTitle = " Kotlin Extension Functions "
val settingsTitle = "Account Settings"
println(topicTitle.toRouteSegment())
println(settingsTitle.toRouteSegment())
}kotlin-extension-functions
account-settingsOmitting this doesn’t change the meaning: trim() still runs on the current string.
If this rule makes sense only in the routing layer, keep the extension in that package or file
instead of exporting a vague name such as normalize() to the whole project.
This simplified rule doesn’t handle Unicode normalization or every URL-encoding requirement. Its name limits the result to a route segment in this project. If product rules are more involved, put them in tests or a dedicated value type instead of piling on implicit behavior.
Combining a generic function and computed property
A generic extension can preserve its element type, while an extension property fits a cheap value
computed from existing state. An empty list has no last index, so the property returns Int?
instead of inventing a sentinel number.
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
val List<*>.lastIndexOrNull: Int?
get() = if (isEmpty()) null else lastIndex
fun main() {
val cities = listOf("Paris", "Lyon", "Nice")
val singleCity = listOf("Paris")
val noCities = emptyList<String>()
println(cities.secondOrNull())
println(singleCity.secondOrNull())
println(cities.lastIndexOrNull)
println(noCities.lastIndexOrNull)
}Lyon
null
2
nullsecondOrNull() delegates boundary behavior to the standard library’s getOrNull() rather
than repeating an index check. lastIndexOrNull is computed from the list’s current state on
every access and uses no additional storage. A later mutation is reflected by the next read.
The receiver is List<*> because the property cares only about size and doesn’t need the element
type. The function needs T so its result retains the concrete element type. Keeping type parameters
on only the operations that need them avoids meaningless generic complexity.
Handling null inside a nullable receiver
A nullable receiver lets callers omit a safe call and centralizes the missing-value policy inside
the extension. This displayNameOr() handles both a missing object and a blank name.
data class Customer(
val givenName: String,
val familyName: String,
)
val Customer.displayName: String
get() = "$givenName $familyName".trim()
fun Customer?.displayNameOr(fallback: String): String {
val name = this?.displayName
return if (name.isNullOrBlank()) fallback else name
}
fun main() {
val customer = Customer("Ada", "Lovelace")
val blank = Customer(" ", " ")
val missing: Customer? = null
println(customer.displayNameOr("Guest"))
println(blank.displayNameOr("Guest"))
println(missing.displayNameOr("Guest"))
}Ada Lovelace
Guest
GuestCalling missing.displayNameOr("Guest") is valid because the extension’s receiver type is
already Customer?. Inside the function, this remains nullable; the code uses a safe call
to obtain the property and lets isNullOrBlank() combine the missing and blank cases.
Writing missing?.displayNameOr("Guest") instead skips the whole extension when the receiver
is null, making the expression evaluate to null rather than "Guest". Whether to use ?.
is a semantic choice, not something a “nullable values always need safe calls” habit can decide.
Observing static dispatch and member precedence
The next program shows both resolution rules.
An Alert-typed variable selects Alert.kind(), but after it enters a Message parameter the
call selects only Message.kind(). The real channel() member shadows the matching extension.
open class Message {
fun channel(): String = "member"
}
class Alert : Message()
fun Message.kind(): String = "message"
fun Alert.kind(): String = "alert"
// This extension cannot beat Message's member with the same signature.
fun Message.channel(): String = "extension"
fun printSummary(message: Message) {
println(message.kind())
println(message.channel())
}
fun main() {
val alert: Alert = Alert()
println(alert.kind())
printSummary(alert)
}alert
message
memberThe object received by printSummary() is still an Alert, but the parameter’s declared type
is Message, so the extension call prints message. If kind() must vary with the runtime
subclass, make it an open member on Message or part of an interface contract.
The compiler warns that Message.channel() is shadowed by a member.
Keeping an extension that can never be selected through ordinary member syntax misleads readers;
delete it or give it a clear name for the distinct operation it performs.
Pitfalls
Fix: Put runtime-dispatched behavior in an overridable member on a base class or interface. If an extension is only a static-type adapter, make that limit clear in its name and parameter type, then test both concrete-typed and base-typed variables.
Fix: Avoid generic member names, especially get(), size(), parse(), and toString().
Check new members and compiler warnings during dependency upgrades, and keep behavior tests for
critical extensions.
Fix: Use the narrowest practical visibility and place domain extensions in the package that owns the rule. Resolve conflicts with explicit imports or aliases instead of depending on star imports and accidental scope order.
Fix: Let an extension property compute only from existing receiver state. Real new state belongs in the original type, a wrapper, or explicit storage. When external storage is unavoidable, expose its key strategy, threading model, and lifetime instead of hiding it in an accessor.
Fix: Inspect the extension’s receiver type first.
If it already accepts Type? and defines null semantics, call it directly. If it accepts only
Type, handle absence with a safe call, Elvis operator, or explicit branch.
Fix: Don’t widen visibility merely to accommodate an extension. Behavior that depends on private invariants belongs inside the type; caller-specific composition should use only stable public APIs.
Two receivers in a member extension
An extension declared inside a class has two object contexts. The instance of the type to the declaration’s left is the extension receiver, while the instance of the containing class is the dispatch receiver. The former supplies the object being extended; the latter supplies the policy or environment that owns the group of extensions.
When both receivers have a member with the same name, the extension receiver’s member wins.
Use a qualified this, such as this@Formatter, to select the dispatch receiver explicitly.
This style carries substantial implicit context, so it earns its place only when the extension
really belongs to a policy owned by that host object.
A member extension can vary through the dispatch receiver’s virtual-member mechanism, but the extension receiver is still selected statically. In other words, the host policy can change dynamically while the runtime subtype of the extended object doesn’t switch extension overloads.
This distinction is easy to blur in generated accessors, serialization policies, and DSL hosts.
During review, write down both receiver types instead of asking only “what is this?” If ownership
is still hard to read, an ordinary function with explicit parameters is often easier to maintain.
Static resolution is also an evolution contract
Compatibility for a public extension depends on more than its own signature. The receiver type’s member set, caller imports, and same-named extensions from other libraries all participate in source resolution. A seemingly unrelated dependency upgrade can make an old call ambiguous or redirect it to a new member after recompilation.
Member precedence protects a class author’s control over the class API, but it doesn’t guarantee
unchanging caller behavior. Library authors should avoid semantically vague names on broad types,
especially Any, String, and List<T>. Internal projects should use package boundaries and
visibility to state which domain owns an extension.
An import alias changes only the name used in the current file; it neither copies the function nor changes its receiver rules. It can clearly distinguish two legitimate implementations, such as encoders for different protocols. If the implementations represent mutually exclusive domain semantics, a wrapper type or explicit service is usually a better long-term boundary.
Don’t test this risk only by unit-testing the extension body. Keep call-site tests that compile with real imports and real static types, and recompile them during dependency upgrades. Those tests catch changes to the candidate set even when the extension body itself is untouched.
JVM representation is not language semantics
On Kotlin/JVM, a top-level extension function is typically lowered to a static method in a generated file facade, with the extension receiver passed as a parameter. A member extension declared inside a class also needs a host instance, so “all extensions become static methods” isn’t an accurate rule.
The file name, @file:JvmName, visibility, and signature affect the Java-facing call form.
Java callers usually invoke a generated file-class method for a top-level extension rather than
using receiver.extension() syntax. Cross-language APIs should verify the actual generated JVM signature.
These are JVM backend representation details, not the reason Kotlin source selects an extension. Kotlin/JS, Kotlin/Native, and other targets may use different representations, while language rules such as member precedence and declared-receiver-type resolution still follow Kotlin semantics.
Explain scope and overload resolution first, then inspect bytecode when interop or debugging requires it. Thinking of an extension as “a function with a receiver parameter” is useful; treating that model as a fixed ABI across every target and declaration location leads to incorrect conclusions.
Further reading
4 questions · 2 predict-the-output · 1 spot-the-bug