Kotlin Extensions — Extension Functions and Properties Guide
In this tutorial, you will learn about Kotlin Extensions. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin extension functions add new methods to existing classes without modifying their source code, using a receiver type declaration that makes the function behave as if it were a member of the extended class.
What You'll Learn
- Write extension functions for built-in and third-party classes
- Create extension properties for computed values
- Understand extension scope and visibility
- Use extension functions with nullable receivers
- Declare companion object extensions
- Apply extensions for domain-specific APIs
- Understand extension dispatch (static versus dynamic)
Why It Matters
Extensions are one of Kotlin's most powerful features. They let you add utility methods to classes you do not own: String, List, Date, third-party library classes. Extension functions make code more readable by letting you write operations as method calls instead of static utility functions. They are essential for writing idiomatic Kotlin and are used extensively in the standard library, Android KTX, and frameworks like Ktor.
Real-World Use
DodaTech uses extension functions to add logging, JSON Serialization, and validation methods to built-in types. String extensions validate email and phone formats. List extensions provide domain-specific filtering for malware signature lists. Context extensions in Android simplify resource access.
Learning Path
flowchart LR A[Sealed Classes] --> B[Extensions\nYou are here] B --> C[Generics] style B fill:#f90,color:#fff
Extension Functions
An extension function is declared with the receiver type prefixing the function name.
// Extension on String
fun String.isEmail(): Boolean {
return this.contains("@") && this.contains(".")
}
// Extension on Int
fun Int.isEven(): Boolean = this % 2 == 0
// Extension on List
fun <T> List<T>.secondOrNull(): T? {
return if (this.size >= 2) this[1] else null
}
fun main() {
println("test@example.com".isEmail()) // Output: true
println("hello".isEmail()) // Output: false
println(4.isEven()) // Output: true
println(5.isEven()) // Output: false
println(listOf(1, 2, 3).secondOrNull()) // Output: 2
println(listOf(1).secondOrNull()) // Output: null
println(emptyList<Int>().secondOrNull()) // Output: null
// Chaining extensions
val result = "Hello, Kotlin!"
.filter { it.isLetterOrDigit() || it == ' ' }
.split(" ")
.map { it.uppercase() }
.joinToString("-")
println(result) // Output: HELLO-KOTLIN
}
Output: Extension functions are called with dot notation on the receiver object.
Extension Properties
Extension properties provide computed values without modifying the original class.
// Extension property on String
val String.wordCount: Int
get() = this.split("\\s+".toRegex()).size
val String.isNullOrBlank: Boolean
get() = this.isBlank()
// Extension property on IntRange
val IntRange.halfway: Int
get() = (this.first + this.last) / 2
// Extension property on collections
val <T> List<T>.middleItem: T?
get() = if (isEmpty()) null else this[size / 2]
fun main() {
val text = "Kotlin extension functions are powerful"
println(text.wordCount) // Output: 5
println((1..10).halfway) // Output: 5
println((0..100).halfway) // Output: 50
println(listOf("a", "b", "c", "d", "e").middleItem) // Output: c
println(emptyList<Int>().middleItem) // Output: null
}
Output: Extension properties behave like computed properties. They cannot have backing fields because they do not modify the class structure.
Nullable Receiver Extensions
Extensions can be defined on nullable types, allowing calls on null references.
// Extension on nullable String
fun String?.isNullOrEmail(): Boolean {
if (this == null) return false
return contains("@") && contains(".")
}
// Extension on nullable List
fun <T> List<T>?.nullSafeSize(): Int {
return this?.size ?: 0
}
fun String?.toSafeLength(): Int {
return this?.length ?: 0
}
fun main() {
val valid: String? = "user@example.com"
val invalid: String? = "not-an-email"
val nullStr: String? = null
println(valid.isNullOrEmail()) // Output: true
println(invalid.isNullOrEmail()) // Output: false
println(nullStr.isNullOrEmail()) // Output: false (no NPE)
println(listOf(1, 2, 3).nullSafeSize()) // Output: 3
val nullList: List<Int>? = null
println(nullList.nullSafeSize()) // Output: 0
println(nullStr.toSafeLength()) // Output: 0
}
Output: Nullable receiver extensions handle null gracefully without explicit null checks at the call site.
Companion Object Extensions
You can extend a companion object, adding static-like methods.
class MyClass {
companion object {
const val VERSION = "1.0.0"
}
}
// Extension on companion object
fun MyClass.Companion.createDefault(): MyClass {
println("Creating default instance (version $VERSION)")
return MyClass()
}
fun MyClass.Companion.fromConfig(config: Map<String, String>): MyClass {
println("Creating from config with ${config.size} entries")
return MyClass()
}
// Extension on any companion
fun <T> T.Companion.safeCall(block: () -> T): T? {
return try {
block()
} catch (e: Exception) {
println("Error: ${e.message}")
null
}
}
fun main() {
val instance = MyClass.createDefault()
// Output: Creating default instance (version 1.0.0)
val configured = MyClass.fromConfig(mapOf("key" to "value"))
// Output: Creating from config with 1 entries
}
Output: Companion object extensions are accessed through the class name, just like regular companion object members.
Extension Scope and Visibility
Extensions are resolved statically, not dynamically. The extension called depends on the declared type of the variable, not its runtime type.
open class Shape
class Circle : Shape()
fun Shape.describe() = "I am a Shape"
fun Circle.describe() = "I am a Circle"
fun main() {
val shape: Shape = Circle()
println(shape.describe()) // Output: I am a Shape (static dispatch)
val circle: Circle = Circle()
println(circle.describe()) // Output: I am a Circle
// Import extensions from other files
// import com.example.extensions.isEmail
// "test@test.com".isEmail() // Available after import
}
Output: Extension resolution is based on the compile-time type. Circle stored in a Shape variable calls Shape's extension.
To import extensions from other packages:
// In file com/example/strings.kt
package com.example.strings
fun String.reverse(): String = this.reversed()
// In another file
import com.example.strings.reverse
// Now "hello".reverse() is available
Member versus Extension Functions
When a class has a member function and an extension function with the same signature, the member wins.
class Greeter {
fun greet() = println("Hello from member")
}
fun Greeter.greet() = println("Hello from extension")
fun main() {
val greeter = Greeter()
greeter.greet() // Output: Hello from member
}
Output: Member functions always take precedence over extension functions with the same signature. This prevents breaking existing code when a class later adds a member with the same name.
Generic Extension Functions
Extensions can be generic, working across multiple types.
// Generic extension on List
fun <T> List<T>.second(): T {
if (size < 2) throw NoSuchElementException("List has less than 2 elements")
return this[1]
}
// Generic extension with type constraint
fun <T : Comparable<T>> List<T>.maxOrFallback(fallback: T): T {
return this.maxOrNull() ?: fallback
}
// Extension on Map with specific types
fun Map<String, Any?>.toConfigString(): String {
return this.entries.joinToString("\n") { (key, value) ->
"$key = $value"
}
}
fun main() {
println(listOf("a", "b", "c").second()) // Output: b
val numbers = listOf(3, 7, 1, 9, 4)
println(numbers.maxOrFallback(0)) // Output: 9
val emptyList = emptyList<Int>()
println(emptyList.maxOrFallback(-1)) // Output: -1
val config = mapOf("host" to "localhost", "port" to 8080)
println(config.toConfigString())
// Output:
// host = localhost
// port = 8080
}
Output: Generic extensions work across all types matching the constraints.
Common Mistakes
Shadowing member functions: If a class adds a member with the same name as your extension in a future version, your extension will no longer be called. Test after library updates.
Using extensions for essential functionality: Extensions are external. They cannot access private members. If a feature requires access to private data, submit a Pull Request to the original library instead.
Overusing extensions when utility functions suffice: Not everything needs to be an extension. If a function does not operate on a primary receiver, a top-level function may be more appropriate.
Forgetting that extensions cannot be overridden: Extensions use static dispatch. Subclass-specific behavior cannot be achieved with extensions. Use member functions for polymorphic behavior.
Defining extensions in the same file as the receiver class: Extensions are meant to extend classes you do not own. If you own the class, add the method directly as a member.
Not importing before use: Extensions defined in other packages must be explicitly imported. IDEs typically auto-import, but CLI compilation will fail.
Practice Questions
- Can an extension function access private members of the receiver class?
Answer: No. Extension functions have the same access as regular external functions. They cannot access private or protected members.
- How is extension function dispatch different from member function dispatch?
Answer: Extension functions use static dispatch (compile-time type). Member functions use dynamic dispatch (runtime type). The declared variable type determines which extension is called.
- Can you define an extension property with a backing field?
Answer: No. Extension properties cannot have backing fields because they cannot add state to existing classes. They must be computed properties only.
- What happens if a member function has the same signature as an extension function?
Answer: The member function always wins. The extension function is never called for that class.
- Challenge: Create a utility library of String extensions for a content management system. Include extensions for slugify, truncate with ellipsis, word count, reading time estimate, and hashtag extraction.
Answer:
fun String.slugify(): String {
return this.lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("[\\s-]+"), "-")
.trim('-')
}
fun String.truncate(maxLength: Int, ellipsis: String = "..."): String {
return if (this.length <= maxLength) this
else this.take(maxLength - ellipsis.length) + ellipsis
}
val String.wordCount: Int
get() = this.split(Regex("\\s+")).size
val String.readingTimeMinutes: Int
get() = (this.wordCount / 200).coerceAtLeast(1)
fun String.extractHashtags(): List<String> {
return Regex("#\\w+").findAll(this).map { it.value.lowercase() }.toList()
}
fun main() {
val title = "Kotlin Extension Functions: A Complete Guide!"
println(title.slugify()) // Output: kotlin-extension-functions-a-complete-guide
val longText = "This is a very long text that needs to be truncated for display in a list view"
println(longText.truncate(30)) // Output: This is a very long text ...
val article = "Kotlin is a modern language. It runs on JVM. It is fun."
println(article.wordCount) // Output: 12
println("${article.readingTimeMinutes} min read") // Output: 1 min read
val post = "Loving #Kotlin and #Extensions! #coding #100DaysOfCode"
println(post.extractHashtags()) // Output: [#kotlin, #extensions, #coding, #100daysofcode]
}
Mini Project
Create a collection of extension functions for a task management application. Requirements:
- String extensions: isValidTaskTitle, toTaskPriority, formatDueDate
- Int extensions: daysUntil (for due dates), toDurationString
- List extensions: sortedByPriority, filterOverdue, groupByStatus
- Extension properties: taskList.completionPercentage
- Nullable receiver extensions for safe access to nullable task fields
This project demonstrates extensions across multiple types in a cohesive domain.
FAQ
What's Next
After mastering extensions, learn generics for writing type-safe reusable code. You can also explore coroutines for asynchronous programming.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro