skip to content

How does the compiler decide between a member call and a nullable-receiver extension, and how does that explain calling an extension on null?

level: seniorimportance: should knowfreq 35%

answer

  1. Members = virtual/runtime; extensions = static/compile-time
  2. Call desugars to static fn(receiver)
  3. Member wins over extension (non-null receiver)
  4. Extensions are not polymorphic
  5. Static type, not runtime class, selects the extension

basics

~20 s

Extensions are picked at compile time from the declared type, while members are looked up on the real object at runtime. Because an extension call just passes the value as an argument, the value can be null, unlike a member call which needs a real object.

solid answer

~40 s

Members are dispatched **virtually** at runtime on a non-null instance; extensions are dispatched **statically** at compile time based on the expression's declared type. The compiler compiles `x.ext()` to a static call where `x` becomes the receiver argument (e.g. `StringsKt.ext(x)`), so a `null` `x` is just a `null` argument — legal. This is why a `T?` extension can be invoked on null while a member cannot. Resolution order: for a non-null receiver, an applicable **member wins** over an extension; extensions are only considered when no member matches. Extensions are NOT polymorphic — an override in a subclass can't change which extension is chosen, since selection uses the static type. For a nullable receiver, there are no members callable on `T?` anyway, so the `T?` extension is the natural and only candidate.

code

kotlin · 6 lines
kotlin
open class A
class B : A()
fun A?.label(): String = if (this is B) "B?" else "A?"

val x: A? = B()
println(x.label()) // "B?" via runtime `is`, but the FUNCTION chosen is A?.label statically

go deeper

for a junior

Knows extensions can be called on null but may not explain why.

for a middle

States static vs virtual dispatch and the receiver-as-argument desugaring.

for a senior

Explains member-wins resolution, non-polymorphism, and how static typing enables nullable-receiver calls.

for a principal

Weighs API-evolution risks (members shadowing extensions) and guides library design around static dispatch.

## Two dispatch mechanisms - **Member functions**: **virtual dispatch**. At runtime, the JVM looks up the actual method on the actual object based on its real class. This requires a live, non-null instance — calling a member on `null` dereferences and throws `NullPointerException`. Kotlin's type system forbids it on a `T?` value at compile time. - **Extension functions**: **static dispatch**. The compiler resolves the call **at compile time** from the **declared (static) type** of the receiver expression. The chosen function is fixed; it does not depend on the object's runtime class. ## The desugaring An extension `fun String?.tag(): String` compiles to a static function whose first parameter is the receiver: ```kotlin // conceptual desugaring fun tag($this$tag: String?): String { ... } // call site x.tag() -> tag(x) ``` Since the receiver becomes an ordinary argument, passing `null` is fine. No dereference occurs until the body decides to do one. **This is the whole reason a nullable-receiver extension can be called on null.** ## Member-vs-extension resolution For a **non-null** receiver: 1. The compiler first looks for an applicable **member** (including inherited). 2. Only if **no member** is applicable does it consider **extensions** in scope. So **members win**. Example: if `Foo` has `fun bar()` and you also import `fun Foo.bar()`, the member is called. ## Not polymorphic Because extensions are resolved statically, they are **not overridable**: ```kotlin open class A class B : A() fun A.who() = "A" fun B.who() = "B" val x: A = B() println(x.who()) // "A" — static type A decides ``` The runtime class `B` is irrelevant; the declared type `A` selects `A.who()`. ## Nullable receiver in this picture For a value typed `String?`, **no member** is callable (members need non-null), so a `String?` extension is the only viable candidate — and it's selected statically. That's consistent and predictable: ```kotlin val s: String? = null println(s.orEmpty()) // resolves String?.orEmpty(), passes null, returns "" ``` ## Practical implication If you add a member to a class that shadows an extension you relied on, behavior can silently change because the member now wins. For library APIs, prefer not to rely on extensions that could be shadowed by future members.

  • If both fun A.who() and fun B.who() exist and the static type is A, which runs for a B instance?
    A.who(), because extension selection uses the static type A, not the runtime class B.
  • Why can adding a member be a breaking change for extension users?
    Members win over extensions, so a newly added member with the same signature silently shadows the extension at call sites.

Members are like asking the person who answers the door (runtime object); extensions are like mailing to a fixed address printed at compile time — the letter is delivered even if nobody is home (null).

saying these in an interview costs you the question

  • Saying extensions are virtually dispatched / overridable
  • Claiming the runtime type selects which extension runs
  • Thinking extensions win over members
  • Believing the compiler inserts null checks to make member calls safe

context