Extension Functions: Add Methods to Classes You Don't Own
Declaring extension functions and properties, what the receiver and this mean, why extensions resolve statically rather than virtually, the member-wins rule, generic and nullable receivers, and how scoping keeps them from becoming a global Utils bucket.
You can’t add a method to String. It’s in the standard library; you don’t own it. So in Java you write StringUtils.capitalize(name) — a free function wearing a class as a costume, called in the wrong order, with the thing you care about buried as the first argument. Kotlin lets you write name.capitalize() instead, even though String isn’t yours. The mechanism is the extension function, and once you see it you spot it everywhere — most of Kotlin’s own standard library is built from them. Everything here was run against Kotlin 2.4.10.
This chapter follows interfaces. Both add behavior to types; extensions do it without touching the type at all.
Declaring one
Write a normal function, but prefix the name with the type you’re extending, the receiver. Inside the body, this is the instance it was called on, and as usual this is often implicit:
fun String.shout(): String = uppercase() + "!" // uppercase() is this.uppercase()
"hello".shout() // "HELLO!"
shout isn’t added to the String class, but at the call site it’s indistinguishable from a method, which is what makes code read in natural subject-verb order.
It’s just a static function in disguise
An extension doesn’t modify the class and can’t see its private members. It compiles to a plain static function taking the receiver as its first argument — the StringUtils.capitalize(name) shape, ergonomics flipped. Two consequences follow, and both are the kind of thing that produces a confused bug report.
First, extensions resolve statically, by the declared type of the variable, not virtually by the runtime type:
open class Animal
class Dog : Animal()
fun Animal.speak() = "generic noise"
fun Dog.speak() = "woof"
val pet: Animal = Dog()
pet.speak() // "generic noise" — chosen by the Animal type, not the Dog object
A real overridden method would print woof. An extension is picked by the type the compiler sees at the call site, so extensions are the wrong tool for polymorphism — use a member function or interface when behavior must vary by runtime type.
Second, a member always wins. If the class already has a method with the same signature, the member is called and your extension is ignored — and the compiler warns you it happened:
class Greeter { fun hi() = "member" }
fun Greeter.hi() = "extension"
Greeter().hi() // "member" — plus: warning: this extension is shadowed by a member
You can’t accidentally override real behavior, and you’re told when an extension is dead on arrival.
Extension properties
The same trick works for properties, as long as they compute their value, since there’s nowhere to store a new field:
val String.firstWord: String
get() = substringBefore(" ")
"hello world".firstWord // "hello"
An extension property is always a getter (and optionally a setter that mutates the receiver); it can never add storage, for the same reason the static-function model gives it no field.
Generic receivers
An extension can be generic, which is how the standard library defines operations that work on any element type — List<T>.first(), Iterable<T>.map, and so on:
fun <T> List<T>.second(): T = this[1]
listOf('a', 'b', 'c').second() // 'b'
You can also constrain the receiver — fun <T : Comparable<T>> List<T>.top(): T — so an extension applies only where the bound holds. This is where “add a method to a type you don’t own” becomes “add a method to every type that fits a shape.”
Nullable receivers
You can extend a nullable type, letting the extension itself handle null — the call is safe even on a null reference:
fun String?.orEmpty(): String = this ?: ""
val name: String? = null
name.orEmpty() // "" — no crash, no safe call needed
Inside such an extension this may be null, so you check it like any nullable value. This is exactly how the standard library’s isNullOrEmpty() and isNullOrBlank() work — the null check lives inside the function, so the caller doesn’t need a ?..
Where they earn their keep, and where they don’t
Extensions keep call sites reading top-to-bottom, left-to-right, instead of inside-out. They’re ideal for small, focused helpers on existing types, and — because an extension is in scope only where it’s imported — for scoping a helper to the file or package where it’s relevant instead of dumping it in a global Utils bucket. Kotlin’s standard library leans on them so heavily that listOf(...).filter { }.map { }.first() is almost entirely extension calls.
The discipline is the flip side of that scoping: an extension expresses augmentation, not core behavior. If a function is fundamental to a type you own, it belongs in the class, where it’s virtual, visible to private state, and always in scope — not in an extension a caller has to remember to import. Use extensions to augment, not to architect.
Final thoughts
The extension function retires one of Java’s most tired patterns — the static utility class — and replaces it with calls that read like the methods you wished the type had. The catch to keep straight is that they’re static, not virtual: dispatched by declared type, always losing to a real member, and therefore great for convenience and wrong for polymorphism. Hold that line and they’re one of the features you’ll miss most going back to Java.
Next, a gotcha that bites every Java developer eventually: what == actually means in Kotlin. That’s equality.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments