Extension Functions
An extension function lets you add a new function to an existing class or type without editing its source code or subclassing it. You can teach Kotlin’s String, List, or your own data class a brand-new method just by writing a top-level function elsewhere. This is one of Kotlin’s most distinctive features: it replaces the static-helper-class style common in Java (StringUtils.isBlank(str)) with code that reads naturally as str.isBlank(). Under the hood, extension functions don’t actually modify the class at all — they’re compiled to ordinary static functions, resolved entirely at compile time. Understanding that one fact is the key to using extension functions well and avoiding their single biggest gotcha.
Overview: How Extension Functions Work
An extension function is declared with a receiver type immediately before the function name: fun String.shout(): String adds a function called shout to String. Inside the function body, this refers to the instance the function was called on — the receiver object — exactly as if you were writing a member function inside the class itself. You can call it exactly like a member: "hello".shout().
The crucial thing to understand is that Kotlin does not actually inject a new member into the class. The Java Virtual Machine has no idea what an “extension function” is. When the Kotlin compiler sees fun String.lastChar(): Char = this[this.length - 1], it compiles it to a regular static method that takes the receiver as its first parameter — roughly equivalent to a Java static method like lastChar(String receiver). The call word.lastChar() is rewritten by the compiler into something like lastChar(word). This is why extension functions cannot access the private or protected members of the class they extend: they are not really part of the class, they are ordinary functions that happen to use dot syntax.
Because extension functions are just syntactic sugar over static functions, they are resolved statically, based on the declared (compile-time) type of the expression, not the actual runtime type. This is fundamentally different from how member function calls work, which use dynamic dispatch and can be overridden by subclasses. If a class defines a member function with the same signature as an extension function, the member always wins — you cannot override a member with an extension, and the extension is effectively invisible for that call. This static-resolution behavior is by far the most common source of confusion for Kotlin newcomers, and it’s covered in detail in the Common Mistakes section below.
Extension functions can also be declared on nullable receiver types, such as String?. Inside the function body, this may be null, so you must check for it before calling any non-nullable member on it — but once you do, Kotlin’s smart-casting narrows this to the non-null type for the rest of the expression. The standard library’s own isNullOrBlank() and isNullOrEmpty() are built exactly this way.
Syntax
The general form of an extension function declaration is:
fun ReceiverType.functionName(param1: ParamType): ReturnType {
// "this" refers to the ReceiverType instance the function is called on
return someValue
}
| Part | Meaning |
|---|---|
ReceiverType |
The type being extended — can be a built-in type (String, List<Int>), your own class, an interface, or a nullable type (String?). |
functionName |
The name callers will use with dot syntax, e.g. value.functionName(). |
this |
Inside the body, refers to the receiver instance the function was called on. |
param1: ParamType |
Ordinary function parameters, exactly as in any other function. |
ReturnType |
The type returned; can be Unit if the function performs an action rather than producing a value. |
Examples
Example 1: A simple extension on String
fun String.lastChar(): Char = this[this.length - 1]
fun main() {
val word = "Kotlin"
println("Last char of \"$word\" is ${word.lastChar()}")
}
Output:
Last char of "Kotlin" is n
The receiver type is String, so lastChar() becomes callable on any String value. Inside the body, this is the string word, so this[this.length - 1] indexes the final character.
Example 2: Extending a generic collection type
fun List<Int>.secondLargest(): Int? {
if (this.size < 2) return null
val sorted = this.sortedDescending()
return sorted[1]
}
fun main() {
val numbers = listOf(4, 1, 7, 3, 9)
println("Second largest: ${numbers.secondLargest()}")
val single = listOf(5)
println("Second largest of single-element list: ${single.secondLargest()}")
}
Output:
Second largest: 7
Second largest of single-element list: null
This extends List<Int>, so it’s only available on lists of Int, not on List<String> or other element types. Notice the return type is Int?: because a list can have fewer than two elements, the function has a genuine “no answer” case, and returning a nullable type instead of throwing or returning a sentinel like -1 is the idiomatic Kotlin way to model that.
Example 3: Extending your own data class
data class Point(val x: Int, val y: Int)
fun Point.distanceTo(other: Point): Double {
val dx = (x - other.x).toDouble()
val dy = (y - other.y).toDouble()
return kotlin.math.sqrt(dx * dx + dy * dy)
}
fun main() {
val p1 = Point(0, 0)
val p2 = Point(3, 4)
println("Distance from $p1 to $p2 is ${p1.distanceTo(p2)}")
}
Output:
Distance from Point(x=0, y=0) to Point(x=3, y=4) is 5.0
Point is a data class, so its toString() is auto-generated, which is why interpolating $p1 prints Point(x=0, y=0) rather than an object hash. Note that distanceTo reads x and y directly without writing this.x; just like inside a member function, unqualified property access inside an extension function implicitly refers to the receiver.
Example 4: Extension function on a nullable receiver
fun String?.isNullOrBlankCustom(): Boolean {
return this == null || this.trim().isEmpty()
}
fun main() {
val a: String? = null
val b: String? = " "
val c: String? = "hello"
println(a.isNullOrBlankCustom())
println(b.isNullOrBlankCustom())
println(c.isNullOrBlankCustom())
}
Output:
true
true
false
The receiver type is String?, so this can be called even on a variable that might be null — something a normal member function could never allow, because you can’t call a member on a null reference. Inside the body, this == null is checked first; after that check fails to short-circuit, the compiler smart-casts this to non-null String, so this.trim() compiles without a safe call.
How It Works Step by Step
Walking through the call word.lastChar() from Example 1:
- The compiler sees that
lastCharis not a member ofString, so it searches for a matching extension function in scope — it findsfun String.lastChar(): Char. - It rewrites the call, conceptually, into a static function call with
wordpassed as the receiver argument — similar to Java’sStringExtensionsKt.lastChar(word)at the bytecode level. - Inside the function, every use of
thisrefers to that passed-in receiver. - The result is computed and returned exactly like any other function call, and
printlnthen interpolates it into the output string.
Because this resolution happens entirely at compile time based on declared types, no virtual dispatch table lookup is involved — extension calls compile down to plain static calls, which is also why they’re just as fast as calling a top-level function directly.
Common Mistakes
Mistake 1: Expecting extension functions to be polymorphic
Extension functions are resolved by the receiver’s declared type, not its runtime type. This surprises people coming from languages where every method call is virtual.
open class Animal
class Dog : Animal()
fun Animal.speak(): String = "Some generic sound"
fun Dog.speak(): String = "Woof"
fun main() {
val animal: Animal = Dog()
println(animal.speak())
}
Output:
Some generic sound
Even though the object at runtime is a Dog, the variable animal is declared as Animal, so the compiler picks the Animal.speak() extension at compile time. If you need dynamic dispatch based on the actual runtime type, use a real member function with open/override instead of extensions:
open class Animal {
open fun speak(): String = "Some generic sound"
}
class Dog : Animal() {
override fun speak(): String = "Woof"
}
fun main() {
val animal: Animal = Dog()
println(animal.speak())
}
Output:
Woof
With a real open member function overridden in Dog, the call now dispatches on the actual runtime type, printing Woof as most people would originally expect.
Mistake 2: Assuming extension functions can reach private members
Because an extension function is compiled as an outside, static function, it only has access to the public (and internal, within the same module) API of the class — never to private or protected members, even though the call site looks like it’s “inside” the class.
class BankAccount(private val balance: Int)
fun BankAccount.printBalance() {
println(balance) // Error: cannot access 'balance': it is private in 'BankAccount'
}
This fails to compile with an unresolved-reference-style error, because balance is private to BankAccount and the extension function lives outside the class. The fix is to expose what the extension needs through the class’s public API:
class BankAccount(val balance: Int)
fun BankAccount.printBalance() {
println("Balance: $balance")
}
fun main() {
val account = BankAccount(500)
account.printBalance()
}
Output:
Balance: 500
Making balance a public val (or adding a public getter method) gives the extension function something it’s actually allowed to read.
Best Practices
- Use extension functions to add utility behavior to types you don’t own — standard library types or classes from a third-party library — instead of writing static helper classes.
- Don’t reach for extension functions when you need polymorphic behavior across a class hierarchy; use an
openmember function withoverrideinstead, since extensions are resolved statically. - Keep extension functions focused and side-effect-light: because they read exactly like member calls, callers reasonably expect them to behave like well-behaved methods on the type.
- Group related extensions in a clearly named file (for example
StringExtensions.kt) so they’re easy to discover and don’t get lost among unrelated top-level declarations. - Avoid giving an extension function the same name and signature as a likely future member of the class — if the class’s author later adds a real member with that signature, it will silently take priority over your extension everywhere it’s in scope.
- Prefer extensions on nullable receiver types (
T?) only when the null-handling logic is genuinely part of the operation’s meaning, as withisNullOrBlank()— otherwise require a non-null receiver and let the caller handle nullability explicitly.
Practice Exercises
- Write
fun Int.isEven(): Booleanthat returns whether the integer is even, then print the result for4and7. Expected output:truethenfalse. - Write
fun List<String>.longestWord(): String?that returns the longest string in the list, ornullif the list is empty. Test it onlistOf("kotlin", "is", "fun")and onemptyList<String>(). - Write an extension function on a nullable
Int?receiver calledorZero()that returns the wrapped value, or0if the receiver isnull. Test it with a non-null and anullvalue and print both results.
Summary
- An extension function adds a callable-with-dot-syntax function to an existing type without modifying its source or subclassing it.
- It’s declared as
fun ReceiverType.functionName(...): ReturnType, andthisinside the body refers to the receiver instance. - Under the hood, extension functions compile to ordinary static functions with the receiver passed as an argument — the class itself is never actually changed.
- Extension calls are resolved by the declared type at compile time, not the runtime type, so they cannot override members and are not polymorphic.
- Extension functions cannot access
privateorprotectedmembers of the type they extend. - Extension functions can be declared on nullable receiver types (
T?), letting you safely handle a possibly-nullvalue with dot syntax, as the standard library does withisNullOrBlank().
