Extensions

Add functions and computed properties to existing types with receiver syntax, then reason about static resolution, member precedence, nullability, and scope.

level intermediate time 11 min at Standard depth
version Kotlin 2.4.10
what

Extensions add functions or computed properties to existing types with member-call syntax, without modifying or inheriting from those types.

trap

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.

fix

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():

ConditionResultReview focus
The declared type has an applicable memberThe member winsA new member can change source resolution after recompilation
No applicable member, but an extension is visibleResolve among applicable extensionsCheck imports, the static receiver type, and arguments
Only the runtime subtype has another extensionIt doesn’t participate dynamicallyUse a virtual member or interface when polymorphism is required
There is no unique applicable declarationCompilation failsRemove 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.

kotlin
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-settings

Omitting 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.

kotlin
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
null

secondOrNull() 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.

kotlin
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
Guest

Calling 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.

kotlin
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
member

The 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.

Deep Two receivers in a member extension

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

checkpoint

4 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?