skip to content

A teammate writes a `fun List<T>.secondOrNull()` extension and is surprised that calling it through a `Collection<T>`-typed reference doesn't compile, while a member would. Explain the underlying resolution rule and why static typing causes these surprises with supertypes.

level: middleimportance: should knowfreq 45%

answer

  1. Extension candidacy = declared type
  2. Supertype reference hides subtype extensions
  3. No runtime narrowing for extensions
  4. Smart-cast/as to expose List extension
  5. Members are inherited; extensions aren't

basics

~10 s

Extensions are picked by the declared type. A List extension isn't visible on a Collection-typed variable, because the compiler only sees Collection. Members would be inherited and resolved on the object instead.

solid answer

~50 s

Extension resolution is purely static: the compiler searches for an applicable extension using the **declared type** of the receiver expression and its supertypes' extensions in scope. An extension defined on `List<T>` is applicable only when the compile-time type is `List` (or a subtype). If the reference is declared as `Collection<T>`, the `List` extension is simply not a candidate, so the call fails to compile even though the runtime object is a `List`. A real member declared on `Collection` (or inherited) would be available and, if open, dispatched virtually on the runtime object. The fix is to either declare the receiver more specifically, smart-cast/`as` to `List`, or define the extension on the broader type you actually call it on. This is the same static-resolution principle behind the `Animal`/`Dog` surprise — extensions follow the type you wrote, not the object you hold.

code

kotlin · 9 lines
kotlin
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)

fun demo(items: Collection<Int>) {
    // items.secondOrNull()         // compile error: receiver is Collection
    if (items is List<Int>) {
        items.secondOrNull()        // OK: smart-cast makes declared type List
    }
    (items as List<Int>).secondOrNull() // OK: explicit cast
}

go deeper

for a junior

Recognizes that the call fails and that the variable's type matters, even if fuzzy on the fix.

for a middle

Explains candidacy is by declared type and proposes smart-cast or explicit cast correctly.

for a senior

Connects it to the broader static-resolution rule, contrasts member inheritance, and reasons about API receiver-type design.

for a principal

Guides API design so receiver types match intended call sites, avoiding hidden subtype-only extension cliffs across a codebase.

## Why this compiles differently than a member Extensions are resolved at **compile time** against the **declared (static) type** of the receiver expression. The compiler asks: "Given this expression's declared type, is there an in-scope extension whose receiver type this expression is assignable to?" If the declared type is a **supertype** that the extension's receiver isn't compatible with, the extension is **not a candidate** — and you get a compile error, not a fallback. ```kotlin fun <T> List<T>.secondOrNull(): T? = getOrNull(1) fun demo(items: Collection<Int>) { // items.secondOrNull() // does NOT compile: receiver type is Collection, not List } ``` The runtime object might genuinely be an `ArrayList`, but the compiler only sees `Collection<Int>`, and there is no `Collection` extension named `secondOrNull`. ## Contrast with a member If `secondOrNull` were a **member** of `Collection` (or inherited), it would resolve through the object: available on any `Collection` reference, and dispatched virtually if `open`. Members are part of the type and its subtypes; extensions are external and bound to the **exact declared receiver type** in scope. ## The general principle - Extension applicability is decided by the **static type**, including its supertypes' extensions, but **not** by narrowing to the runtime type. - A supertype-typed receiver hides subtype-only extensions. A subtype-typed receiver can see both its own and inherited supertype extensions. ## Fixes ```kotlin fun demo(items: Collection<Int>) { if (items is List<Int>) { items.secondOrNull() // smart-cast: declared type is now List in this block } (items as List<Int>).secondOrNull() // explicit cast } ``` Or widen the extension's receiver to `Collection<T>` if the operation is meaningful there (note: `Collection` has no positional `get`, so you'd iterate). Smart-cast works because after `is List`, the compile-time type **inside the block** becomes `List`, making the extension a candidate. ## Takeaway Extensions never "discover" subtype capabilities through a supertype reference. Choose the receiver type deliberately, or cast to expose subtype-only extensions.

  • Why does `if (items is List) { items.secondOrNull() }` compile while the bare call doesn't?
    Smart-cast changes the compile-time type inside the block to `List`, making the `List` extension a valid candidate.
  • If you had declared `secondOrNull` as a member of `Collection`, would the `Collection`-typed call compile?
    Yes — members are part of the type and inherited by all subtypes, so the call resolves on any `Collection` reference.

saying these in an interview costs you the question

  • Expecting the compiler to narrow to the runtime List automatically
  • Saying extensions are inherited like members
  • Thinking a supertype reference can still see subtype-only extensions
  • Not knowing smart-cast changes the static receiver type
  • Confusing compile error here with a runtime ClassCastException

context