skip to content

Compare designing an extension with a nullable receiver `fun T?.foo()` versus a non-null receiver called via `?.foo()`. What are the semantic and API-design differences?

level: seniorimportance: should knowfreq 38%

answer

  1. Nullable receiver → always runs, body owns null
  2. `?.` short-circuits → null in, null out
  3. Nullable receiver can return non-null type
  4. Name clearly: orEmpty / isNullOr...
  5. Static resolution by declared type

basics

~20 s

A nullable-receiver extension runs even when the value is null and decides what null means. With a non-null receiver and ?., the whole call is skipped when null and the result is null. They behave differently for null inputs.

solid answer

~40 s

With `fun T?.foo(): R`, the function is **always invoked**, even on null, and the body defines null's meaning (e.g. `isNullOrEmpty()` returns `true` for null). With `fun T.foo(): R` called as `value?.foo()`, the safe call **short-circuits**: if `value` is null the call is skipped and the expression evaluates to `null` (type `R?`), so the function never sees null. Design implications: use a nullable receiver when null has a meaningful, total result (a default, a boolean, an empty collection) and you want a non-null return type; use a non-null receiver + `?.` when 'no value' should propagate as null. Nullable receivers also let you keep call sites clean (`name.orEmpty()` vs `name?.let { it } ?: ""`). Stdlib follows this: `isNullOrEmpty`, `orEmpty`, `Any?.toString` take nullable receivers deliberately; most others are non-null and you compose with `?.`.

code

kotlin · 8 lines
kotlin
fun List<Int>?.sumOrZero(): Int = this?.sum() ?: 0   // always defined, returns Int
fun List<Int>.firstOr(d: Int): Int = firstOrNull() ?: d // non-null receiver

fun main() {
    val a: List<Int>? = null
    println(a.sumOrZero())       // 0  (function ran on null)
    println(a?.firstOr(-1))      // null (safe call short-circuited)
}

go deeper

for a junior

Knows ?. skips the call on null while a nullable-receiver extension still runs.

for a middle

Explains the differing result types (null-propagating vs non-null) and picks an approach for a simple case.

for a senior

Gives a clear decision guide, cites stdlib precedents, and accounts for static resolution and chain-breaking.

for a principal

Frames null-handling as an API contract decision affecting downstream nullability, readability, and surprise; sets naming conventions for a codebase.

## Two strategies for null ### A. Nullable receiver — the function owns null ```kotlin fun CharSequence?.isNullOrBlank(): Boolean = this == null || this.isBlank() val s: String? = null println(s.isNullOrBlank()) // true — function ran, decided null means "blank" ``` - The function is **always called**, even on null. - Return type can be **non-null** (`Boolean`, `String`) because the body produces a value for null. - Call site stays flat: `s.isNullOrBlank()`. ### B. Non-null receiver + safe call — the caller owns null ```kotlin fun CharSequence.firstChar(): Char = this[0] val s: String? = null val c: Char? = s?.firstChar() // null — call skipped entirely ``` - `?.` **short-circuits**: null in ⇒ null out, function never runs on null. - Result type becomes **nullable** (`Char?`). - The caller must keep handling null downstream. ## Decision guide | Want… | Choose | |-------|--------| | A total result defined for null (default/empty/boolean) | nullable receiver | | Non-null return type even when input is null | nullable receiver | | 'No value' should propagate as null | non-null receiver + `?.` | | Function logic that is meaningless for null | non-null receiver | ## Subtle points - **Resolution is static**: `s.foo()` picks the extension by the *declared* type of `s`. If both a `T.foo()` and a `T?.foo()` are in scope, the receiver's nullability of the expression determines which applies; a non-null value can call the nullable-receiver one (null is just never the input). - **Chaining**: a nullable-receiver extension that returns non-null breaks an unwanted null-propagation chain — `list.orEmpty().map { ... }` never NPEs. - **Readability vs hidden behavior**: nullable receivers hide null handling inside; that's great for `orEmpty` but can surprise readers if the null semantics are non-obvious. Name it clearly (`...OrEmpty`, `isNullOr...`). ## Example contrast ```kotlin // Nullable receiver: always defined fun List<Int>?.sumOrZero(): Int = this?.sum() ?: 0 // Non-null + safe call: caller deals with null fun List<Int>.average2(): Double = sum().toDouble() / size val avg: Double? = nums?.average2() ```

  • If both `fun String.foo()` and `fun String?.foo()` are imported, which is called on a non-null `String`?
    Both are applicable, but the non-null `String.foo()` is the more specific receiver and wins overload resolution for a non-null expression; the nullable one applies when the expression's type is nullable.
  • Why does a nullable-receiver extension help break null-propagation chains?
    It can return a non-null type (e.g. `orEmpty(): List<T>`), so subsequent calls in the chain operate on a guaranteed non-null value and never need further `?.`.

Nullable receiver is a vending machine that gives a default snack when you insert nothing; ?. is a turnstile that simply won't let an empty-handed person through.

saying these in an interview costs you the question

  • Claiming `?.foo()` still runs the function on null
  • Saying nullable receiver must return a nullable type
  • Ignoring naming so null semantics are hidden/surprising
  • Confusing static extension resolution with virtual dispatch
  • Believing the two designs behave identically for null inputs

context