Introduction

There are often a bunch of tool classes in projects:

StringUtils.isEmail(value)
DateUtils.format(time)
UserUtils.displayName(user)
ListUtils.secondOrNull(items)

The code runs, but reading it always feels like digging through a toolbox.

The goal of Kotlin Extension is not to "sneak a new method into a class", but to put this kind of auxiliary logic back into a more natural position:

value.isEmail()
time.toDisplayText()
user.displayName
items.secondOrNull()

The calling method is like a member function, but the original class is not actually changed.

This is critical.

Extension looks like "extending a class" and is essentially still an ordinary function, except that the first parameter is replaced by the object before the dot. Understanding this, many pitfalls will become clear: why extensions cannot override member functions, why extensions do not have runtime polymorphism, and why extension properties cannot save fields.

The first extension function: let the string determine the email address by itself

Let’s look at a small requirement first: determine whether a string is an email address.

Ordinary tool function writing method:

fun isEmail(value: String): Boolean {
    return value.contains("@") && value.contains(".")
}

fun main() {
    println(isEmail("tom@example.com"))
}

Replace it with an extension function:

fun String.isEmail(): Boolean {
    return contains("@") && contains(".")
}

fun main() {
    println("tom@example.com".isEmail())
    println("not-email".isEmail())
}

Output:

true
false

fun String.isEmail() inside String Called the receiver type, the one before the dot "tom@example.com" Call the receiver object.

Inside the extension function, you can directly access the public members of the receiver:

fun String.isEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

this Points to the string preceding the dot. most of the time this can be omitted, so contains("@") Equivalent to this.contains("@").

The essence of Extension: a more convenient static function

The following extension:

fun String.maskPhone(): String {
    if (length != 11) {
        return this
    }

    return take(3) + "****" + takeLast(4)
}

fun main() {
    println("13800138000".maskPhone())
}

Output:

138****8000

From the calling experience,maskPhone() like String Own method.

but it doesn't really go in String kind. It can be roughly understood as:

fun maskPhone(receiver: String): String {
    if (receiver.length != 11) {
        return receiver
    }

    return receiver.take(3) + "****" + receiver.takeLast(4)
}

The compiler only allows the following writing:

"13800138000".maskPhone()

This design brings two results:

  • The extension cannot access the recipient's private member
  • Extensions are not virtual functions, there is no runtime polymorphism

Demo: Use extension functions to organize order display logic

There is often a bunch of formatting logic in business code.

First define the order model:

import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

data class Order(
    val id: Long,
    val buyerName: String?,
    val amount: BigDecimal,
    val createdAt: LocalDateTime,
    val status: OrderStatus
)

enum class OrderStatus {
    Pending,
    Paid,
    Canceled
}

Without extensions, the page or interface layer may be littered with these judgments:

fun buildOrderLine(order: Order): String {
    val buyer = if (order.buyerName.isNullOrBlank()) {
        "anonymous customer"
    } else {
        order.buyerName
    }

    val amount = "¥" + order.amount.setScale(2, RoundingMode.HALF_UP)
    val time = order.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
    val status = when (order.status) {
        OrderStatus.Pending -> "To be paid"
        OrderStatus.Paid -> "paid"
        OrderStatus.Canceled -> "Canceled"
    }

    return "#${order.id} $buyer $amount $time $status"
}

The logic is not complicated, but all the details are squeezed into one function.

Stable display rules can be split into extensions:

fun String?.orGuestName(): String {
    return if (isNullOrBlank()) {
        "anonymous customer"
    } else {
        this
    }
}

fun BigDecimal.toMoneyText(): String {
    return "¥" + setScale(2, RoundingMode.HALF_UP).toPlainString()
}

fun LocalDateTime.toMinuteText(): String {
    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
    return format(formatter)
}

fun OrderStatus.toChineseText(): String {
    return when (this) {
        OrderStatus.Pending -> "To be paid"
        OrderStatus.Paid -> "paid"
        OrderStatus.Canceled -> "Canceled"
    }
}

fun Order.toListLine(): String {
    return "#$id ${buyerName.orGuestName()} ${amount.toMoneyText()} ${createdAt.toMinuteText()} ${status.toChineseText()}"
}

use:

fun main() {
    val order = Order(
        id = 1001,
        buyerName = null,
        amount = BigDecimal("99.8"),
        createdAt = LocalDateTime.of(2026, 7, 8, 10, 30),
        status = OrderStatus.Paid
    )

    println(order.toListLine())
}

Output:

#1001 anonymous customer ¥99.80 2026-07-08 10:30 paid

Extension functions are not meant to shove all the logic next to the model, but are suitable for encapsulating "small, stable actions that recur around a certain type."

How to display the amount, how to display the time, and how to display the status are all suitable for writing extensions.

Nullable receiver: null can also be pointed out directly

Kotlin extensions can be declared on nullable types.

fun String?.orDash(): String {
    return if (this.isNullOrBlank()) {
        "-"
    } else {
        this
    }
}

fun main() {
    val nickname: String? = null
    val city: String? = "Shanghai"

    println(nickname.orDash())
    println(city.orDash())
}

Output:

-
Shanghai

here nickname yes null,but nickname.orDash() can still be called because the extended receiver type is String?.

Nullable extensions are often used for undercover display:

fun String?.toSearchKeyword(): String {
    return this
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
        ?: "*"
}

fun main() {
    println(null.toSearchKeyword())
    println("  kotlin  ".toSearchKeyword())
}

Output:

*
kotlin

in the standard library Any?.toString() It's a similar idea: even if the recipient is null, you can also get the string "null".

Generic extensions: collection tools don’t need to be copied everywhere

Extensions can take generics. This is especially common on collection classes.

fun <T> List<T>.secondOrNull(): T? {
    return if (size >= 2) this[1] else null
}

fun <T> List<T>.headAndTail(): Pair<T, List<T>>? {
    if (isEmpty()) {
        return null
    }

    return first() to drop(1)
}

fun main() {
    println(listOf("A", "B", "C").secondOrNull())
    println(listOf(10, 20, 30).headAndTail())
    println(emptyList<Int>().headAndTail())
}

Output:

B
(10, [20, 30])
null

Such extensions are best placed in explicit packages, for example:

com.example.common.collection

Import where needed:

import com.example.common.collection.secondOrNull

Don't throw all your extensions into one Extensions.kt in the file. If the file is large, searching and naming conflicts will become troublesome.

More common disassembly methods:

StringExtensions.kt
CollectionExtensions.kt
MoneyExtensions.kt
TimeExtensions.kt
ViewExtensions.kt

Extended attributes: looks like fields, but actually can only be calculated

Extensions can not only write functions, but also properties.

data class User(
    val firstName: String,
    val lastName: String
)

val User.fullName: String
    get() = "$firstName $lastName"

fun main() {
    val user = User("Tom", "Green")
    println(user.fullName)
}

Output:

Tom Green

Extended attributes cannot be written directly like this:

val User.fullName: String = "$firstName $lastName"

The reason is that the extension has no behind-the-scenes field, which means there is no place to actually save this value.

can only pass get() calculate:

val String.lastChar: Char?
    get() = if (isEmpty()) null else this[length - 1]

var Extended attributes can also exist, but they must be able to convert the assignment action into an operation on an existing object.

var StringBuilder.lastChar: Char
    get() = this[length - 1]
    set(value) {
        setCharAt(length - 1, value)
    }

fun main() {
    val builder = StringBuilder("abc")

    println(builder.lastChar)
    builder.lastChar = 'z'
    println(builder)
}

Output:

c
abz

Not given here StringBuilder To add a new field, just put lastChar = 'z' Converted to setCharAt(...).

It is not recommended to use external Map Forcibly suspend the state of an object:

private val tags = mutableMapOf<Any, String>()

var Any.tag: String?
    get() = tags[this]
    set(value) {
        if (value == null) {
            tags.remove(this)
        } else {
            tags[this] = value
        }
    }

This way of writing will bring life cycle, memory leaks, thread safety and object equality issues. Unless the boundaries are very clear, extended attributes are more suitable for "calculated properties" than "hidden fields".

Member function priority: extension cannot cover the original method

When there is already a member function with the same name in the class, the member function takes precedence.

class Message {
    fun text(): String {
        return "member"
    }
}

fun Message.text(): String {
    return "extension"
}

fun main() {
    println(Message().text())
}

Output:

member

Extension functions do not have the ability to override member functions.

So don't "transform" the semantics of existing APIs through extensions. For methods already provided by the class itself, member functions are preferred; extensions are more suitable to supplement missing capabilities.

Prioritizing member functions also has a practical impact: after the library is upgraded, a new member function with the same name is added to the original class, and the original place where the extension is called may become a call to the member function.

For example, there is no such thing in the old version library summary(), the extension was written in the project:

class Report(
    val title: String,
    val total: Int
)

fun Report.summary(): String {
    return "$title: $total"
}

If follow-up Report The class itself has been added summary(), the calling site will give priority to member functions. This change may affect behavior.

It is best to be specific when naming public extensions and use them sparingly. run, parse, format, convert Such an overly broad name.

Static resolution: the declared type determines which extension is called

Extensions have no runtime polymorphism.

open class Shape
class Rectangle : Shape()

fun Shape.name(): String {
    return "Shape"
}

fun Rectangle.name(): String {
    return "Rectangle"
}

fun printName(shape: Shape) {
    println(shape.name())
}

fun main() {
    val rectangle = Rectangle()

    println(rectangle.name())
    printName(rectangle)
}

Output:

Rectangle
Shape

rectangle.name() The variable declaration type is Rectangle, so call Rectangle.name().

printName(shape: Shape) The parameter declaration type here is Shape, so call Shape.name(). What is passed in during runtime is Rectangle, it will not be changed to Rectangle.name().

When polymorphism is really needed, member functions should be used:

open class Animal {
    open fun sound(): String {
        return "unknown"
    }
}

class Dog : Animal() {
    override fun sound(): String {
        return "wang"
    }
}

fun main() {
    val animal: Animal = Dog()
    println(animal.sound())
}

Output:

wang

The conclusion is straightforward: extensions are suitable for "complementing tools" but not for "doing polymorphism".

Extensions cannot access private and protected

Extension functions are not internal members of the class, so they cannot access private properties.

class Account(
    private val balance: Int
)

fun Account.canPay(amount: Int): Boolean {
    // return balance >= amount
    return amount > 0
}

in the comments above balance Access will fail to compile.

When you need to expose your judgment capabilities, you can write member functions inside the class:

class Wallet(
    private val balance: Int
) {
    fun canPay(amount: Int): Boolean {
        return balance >= amount
    }
}

Extensions are not backdoors that break encapsulation. It can only use the capabilities exposed by the recipient.

Companion object extension: append factory method to class name

If the class has companion object, you can write extensions to companion objects.

data class User(
    val id: Long,
    val name: String
) {
    companion object
}

fun User.Companion.guest(): User {
    return User(id = 0, name = "Guest")
}

fun main() {
    val user = User.guest()
    println(user)
}

Output:

User(id=0, name=Guest)

It looks like a static method when called, but the essence is still an extension.

Companion object extensions are suitable for supplementing external factories, test data construction, and protocol conversion:

fun User.Companion.fromCsv(line: String): User {
    val parts = line.split(",")
    return User(
        id = parts[0].toLong(),
        name = parts[1]
    )
}

fun main() {
    println(User.fromCsv("100,Tom"))
}

Output:

User(id=100, name=Tom)

Member expansion: restrict expansion to a certain context

Extension functions can be written inside classes.

class HtmlBuilder {
    private val lines = mutableListOf<String>()

    fun String.h1() {
        lines += "<h1>$this</h1>"
    }

    fun String.p() {
        lines += "<p>$this</p>"
    }

    fun build(block: HtmlBuilder.() -> Unit): String {
        block()
        return lines.joinToString("\n")
    }
}

fun main() {
    val html = HtmlBuilder().build {
        "Kotlin Extension".h1()
        "extension let DSL It feels more natural to write".p()
    }

    println(html)
}

Output:

<h1>Kotlin Extension</h1>
<p>extension let DSL It feels more natural to write</p>

String.h1() and String.p() only in HtmlBuilder It is visible in the context and cannot be called directly from outside.

This way of writing is common in DSL. Its advantage is to limit the expansion capability to a specific scope and avoid polluting the global namespace.

There are two receivers in the member extension:

class Connection(
    val host: String,
    val port: Int
) {
    fun String.withPort(): String {
        return "$this:$port"
    }

    fun connect() {
        println(host.withPort())
    }
}

fun main() {
    Connection("kotlinlang.org", 443).connect()
}

Output:

kotlinlang.org:443

There are two objects involved here:

String is an extension receiver
Connection is a distribution recipient

this The default is to point to the closer recipient, that is String. When you need to access outer objects, you can use tags:

class Connection(
    val host: String,
    val port: Int
) {
    fun String.fullAddress(): String {
        return "${this}@${this@Connection.port}"
    }
}

Multi-receiver code is easy to read and is suitable for DSL and local encapsulation, but not suitable for spreading everywhere.

Extended function types: the foundation behind standard library scoped functions

There is another very important way of writing in Kotlin:

val block: StringBuilder.() -> Unit = {
    append("Hello")
    append(", ")
    append("Kotlin")
}

StringBuilder.() -> Unit Call the function type with the receiver.

use:

fun buildText(block: StringBuilder.() -> Unit): String {
    val builder = StringBuilder()
    builder.block()
    return builder.toString()
}

fun main() {
    val text = buildText {
        append("Hello")
        append(", ")
        append("Kotlin")
    }

    println(text)
}

Output:

Hello, Kotlin

This is not an "extension function declaration", but it is the same idea as extending the receiver: inside the code block this yes StringBuilder.

apply The simplified version can be understood like this:

inline fun <T> T.configure(block: T.() -> Unit): T {
    block()
    return this
}

data class ServerConfig(
    var host: String = "localhost",
    var port: Int = 8080,
    var enableSsl: Boolean = false
)

fun main() {
    val config = ServerConfig().configure {
        host = "api.example.com"
        port = 443
        enableSsl = true
    }

    println(config)
}

Output:

ServerConfig(host=api.example.com, port=443, enableSsl=true)

This type of writing is the basis for Kotlin DSL, Gradle Kotlin DSL, HTML DSL, and test DSL.

Demo: Use extension to write a simple routing DSL

Next, use the extension function type to make a small route register.

class Router {
    private val routes = mutableMapOf<String, () -> String>()

    fun get(path: String, handler: () -> String) {
        routes["GET $path"] = handler
    }

    fun post(path: String, handler: () -> String) {
        routes["POST $path"] = handler
    }

    fun handle(method: String, path: String): String {
        return routes["$method $path"]?.invoke() ?: "404 Not Found"
    }
}

fun router(block: Router.() -> Unit): Router {
    val router = Router()
    router.block()
    return router
}

fun main() {
    val app = router {
        get("/health") {
            "OK"
        }

        post("/orders") {
            "order created"
        }
    }

    println(app.handle("GET", "/health"))
    println(app.handle("POST", "/orders"))
    println(app.handle("GET", "/missing"))
}

Output:

OK
order created
404 Not Found

router { ... } It can be called directly inside get and post, the reason is that the receiver of the code block is Router.

This way of writing is closer to declarative configuration than the following:

val router = Router()
router.get("/health") { "OK" }
router.post("/orders") { "order created" }

Small configurations, builders, and test data generation all fit this pattern.

Extensions and imports: not defined but visible to the whole project

Top-level extensions are controlled by packages and imports.

For example:

package com.example.text

fun String.toSlug(): String {
    return lowercase()
        .trim()
        .replace(Regex("\\s+"), "-")
}

Used in another file:

package com.example.article

import com.example.text.toSlug

fun main() {
    println("Kotlin Extension Guide".toSlug())
}

Output:

kotlin-extension-guide

The extended visibility is the same as that of a normal function, and you can write private, internal, public.

A dedicated extension within the file is suitable to be written as private:

private fun String.normalizeKeyword(): String {
    return trim().lowercase()
}

Common extensions within the module are suitable for internal:

internal fun String.toCacheKey(): String {
    return trim().lowercase().replace(" ", ":")
}

Public API extensions should be named carefully as they will enter the caller's completion list.

Extension functions can also be operators and infix

Extension functions can be used with operator.

data class Point(
    val x: Int,
    val y: Int
)

operator fun Point.plus(other: Point): Point {
    return Point(
        x = x + other.x,
        y = y + other.y
    )
}

fun main() {
    val result = Point(1, 2) + Point(3, 4)
    println(result)
}

Output:

Point(x=4, y=6)

Can also cooperate infix Write a DSL that is closer to natural language.

data class Header(
    val name: String,
    val value: String
)

infix fun String.toHeader(value: String): Header {
    return Header(this, value)
}

fun main() {
    val header = "Content-Type" toHeader "application/json"
    println(header)
}

Output:

Header(name=Content-Type, value=application/json)

operator and infix It shouldn't be used just to show off your skills. Symbols or infix expressions must be intuitive, otherwise ordinary function names are clearer.

Typical practice: Repository query result conversion

In back-end code, database entities are often converted into interface DTOs.

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

data class UserEntity(
    val id: Long,
    val username: String,
    val email: String?,
    val enabled: Boolean,
    val createdAt: LocalDateTime
)

data class UserResponse(
    val id: Long,
    val username: String,
    val email: String,
    val status: String,
    val createdAt: String
)

The conversion logic can be written as an extension:

fun Boolean.toEnabledText(): String {
    return if (this) "enable" else "Disable"
}

fun LocalDateTime.toApiTime(): String {
    return format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
}

fun String?.toEmailText(): String {
    return this?.takeIf { it.isNotBlank() } ?: "-"
}

fun UserEntity.toResponse(): UserResponse {
    return UserResponse(
        id = id,
        username = username,
        email = email.toEmailText(),
        status = enabled.toEnabledText(),
        createdAt = createdAt.toApiTime()
    )
}

fun main() {
    val entity = UserEntity(
        id = 1,
        username = "tom",
        email = null,
        enabled = true,
        createdAt = LocalDateTime.of(2026, 7, 8, 9, 15, 30)
    )

    println(entity.toResponse())
}

Output:

UserResponse(id=1, username=tom, email=-, status=enable, createdAt=2026-07-08 09:15:30)

This type of extension is suitable to be placed closer to the business boundary, such as:

adapter/http/UserResponseMapper.kt

Don't put it into the global picture common Bag. because UserEntity.toResponse() It belongs to the current interface return format and is not necessarily a universal conversion for all scenarios.

Typical practice: Android View prevents repeated clicks

View extensions are very common in Android projects. Use the simplified version below View Simulate anti-repeat click logic.

class View {
    private var clickListener: (() -> Unit)? = null

    fun setOnClickListener(listener: () -> Unit) {
        clickListener = listener
    }

    fun performClick() {
        clickListener?.invoke()
    }
}

fun View.setDebouncedClickListener(
    intervalMillis: Long = 800,
    nowProvider: () -> Long = System::currentTimeMillis,
    onClick: () -> Unit
) {
    var lastClickAt = 0L

    setOnClickListener {
        val now = nowProvider()
        if (now - lastClickAt >= intervalMillis) {
            lastClickAt = now
            onClick()
        }
    }
}

fun main() {
    val button = View()
    var now = 1000L

    button.setDebouncedClickListener(
        intervalMillis = 800,
        nowProvider = { now }
    ) {
        println("submit")
    }

    button.performClick()
    now += 300
    button.performClick()
    now += 800
    button.performClick()
}

Output:

submit
submit

The second click was only 300ms away from the first and was blocked.

This kind of extension is better than inheriting a DebouncedButton lighter. It does not change the type of View, but just adds common behaviors at the calling layer.

Real Android code can be directly extended android.view.View:

fun View.setDebouncedClickListener(
    intervalMillis: Long = 800,
    onClick: (View) -> Unit
) {
    var lastClickAt = 0L

    setOnClickListener { view ->
        val now = System.currentTimeMillis()
        if (now - lastClickAt >= intervalMillis) {
            lastClickAt = now
            onClick(view)
        }
    }
}

Typical practice: Result result processing

Assume that the interface call returns a uniform result:

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Failure(val code: String, val message: String) : ApiResult<Nothing>()
}

You can add some chain operations to the result type:

inline fun <T, R> ApiResult<T>.mapData(transform: (T) -> R): ApiResult<R> {
    return when (this) {
        is ApiResult.Success -> ApiResult.Success(transform(data))
        is ApiResult.Failure -> this
    }
}

inline fun <T> ApiResult<T>.onFailure(block: (String, String) -> Unit): ApiResult<T> {
    if (this is ApiResult.Failure) {
        block(code, message)
    }

    return this
}

fun main() {
    val result: ApiResult<Int> = ApiResult.Success(100)

    val textResult = result
        .mapData { "score=$it" }
        .onFailure { code, message ->
            println("$code $message")
        }

    println(textResult)
}

Output:

Success(data=score=100)

Such extensions can centralize common result processing rules. The call point retains the main line of business and does not need to be written repeatedly. when.

How to choose extension, inheritance and member functions

The three solve different problems.

planSuitable for the sceneNot suitable for the scene
member functionType's own core behavior, need to access private state, need polymorphismThird-party classes, JDK classes, display conversions only used in a certain module
inheritClear is-a relationship, need to override parent class behaviorI just want to add a few tool methods, the original class is final, and the combination is more suitable for the scenario.
extension functionNo changes to original class supplementary capabilities, type-related tools, partial DSL, formatting and conversionNeed to truly override behavior, need to save state, need runtime polymorphism

A simple judgment:

Is this behavior a core capability of the type??

It is the core capability and gives priority to member functions.

Is this behavior an auxiliary ability for a certain calling scenario??

It is an auxiliary capability and can be considered for expansion.

Does this behavior require different implementations for multiple subtypes??

Requires member functions and polymorphism, not suitable for expansion.

Common pitfall 1: too many extensions, and the completion list becomes a garbage dump

If you write too many extensions, a large number of functions will appear in the IDE completion.

For example, give String Write dozens of extensions:

fun String.toUserId(): Long = trim().toLong()
fun String.toOrderId(): Long = trim().toLong()
fun String.toSkuId(): Long = trim().toLong()
fun String.toTenantId(): Long = trim().toLong()

These functions may seem convenient, but they actually pollute all string call sites.

A more stable approach is to let the domain type take over the semantics:

@JvmInline
value class UserId(val value: Long)

fun String.toUserId(): UserId {
    return UserId(trim().toLong())
}

Even put the conversion directly closer to the input parsing position instead of having it owned by the whole project. String All have business meanings.

Common Pitfall 2: The extension name is too large

This kind of name is dangerous:

fun String.parse(): Any {
    return this
}

fun String.convert(): String {
    return trim()
}

fun Any.format(): String {
    return toString()
}

The problem isn't that the code doesn't work, it's that the meaning is too broad. The specific rules are difficult to see at the call site.

Better naming should bring business semantics:

fun String.parseOrderId(): Long {
    return trim().toLong()
}

fun String.toSearchKeyword(): String {
    return trim().lowercase()
}

fun BigDecimal.toCnyText(): String {
    return "¥" + setScale(2, RoundingMode.HALF_UP).toPlainString()
}

The extension function name should answer a question: what will the object before the dot become after this action.

Common pitfall 3: Hiding complex business processes in extensions

The following extension is not appropriate:

fun Order.payAndSendMessage() {
    // Deduct inventory
    // transfer payment
    // write order
    // send text message
    // Push MQ
}

The call site looks like just a normal method:

order.payAndSendMessage()

But behind the scenes there may be transactions, networks, messages, retries and state changes. The syntax of extension functions is too light and easily hides its weight.

Complex business processes are more suitable to be put into service objects:

class OrderPaymentService {
    fun pay(orderId: Long) {
        // Orchestrate payment process
    }
}

Extensions can do small, deterministic transformations and are not suitable for masquerading as domain services.

Common Pitfall 4: Extensions and members with the same name lead to misjudgment

Member functions take precedence, so this code is easily misleading:

class Text {
    fun trim(): String {
        return "member trim"
    }
}

fun Text.trim(): String {
    return "extension trim"
}

Call:

println(Text().trim())

The output must be:

member trim

The extension function name should not have the same name as an existing member of the receiver. Even if the current version does not have a member with the same name, it may appear after the public library is upgraded.

Common Pitfall 5: Treating Extensions as Monkey Patch

Some languages ​​can add methods to classes at runtime, or even modify existing methods. Kotlin Extension is not such a mechanism.

It does not have these capabilities:

The original class bytecode cannot be modified
Member functions cannot be overridden
Cannot access private member
Cannot add fields to object
Extensions cannot be dynamically selected based on runtime type

So Kotlin extensions are more like "static utility functions with receivers" than runtime patches.

What does interoperability with Java look like?

After Kotlin top-level extensions are compiled into the JVM, they are essentially static methods.

Assume the filename is TextExtensions.kt:

package com.example.text

fun String.maskPhone(): String {
    if (length != 11) {
        return this
    }

    return take(3) + "****" + takeLast(4)
}

The Java call is similar to:

String result = TextExtensionsKt.maskPhone("13800138000");

can pass @file:JvmName Change the generated class name:

@file:JvmName("TextExt")

package com.example.text

fun String.maskPhone(): String {
    if (length != 11) {
        return this
    }

    return take(3) + "****" + takeLast(4)
}

The Java call becomes:

String result = TextExt.maskPhone("13800138000");

The Kotlin call is still:

"13800138000".maskPhone()

This also illustrates again: extensions are not member methods of the receiver class at the JVM level.

A complete small demo: order list interface conversion

Let’s string together the previous concepts: nullable expansion, amount expansion, time expansion, entity conversion to DTO, and collection expansion.

import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

data class OrderEntity(
    val id: Long,
    val buyerName: String?,
    val amount: BigDecimal,
    val status: String,
    val createdAt: LocalDateTime
)

data class OrderItemResponse(
    val id: Long,
    val buyerName: String,
    val amount: String,
    val status: String,
    val createdAt: String
)

fun String?.orAnonymous(): String {
    return this?.takeIf { it.isNotBlank() } ?: "anonymous customer"
}

fun BigDecimal.toYuanText(): String {
    return "¥" + setScale(2, RoundingMode.HALF_UP).toPlainString()
}

fun LocalDateTime.toSecondText(): String {
    return format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
}

fun String.toOrderStatusText(): String {
    return when (this) {
        "PENDING" -> "To be paid"
        "PAID" -> "paid"
        "CANCELED" -> "Canceled"
        else -> "unknown"
    }
}

fun OrderEntity.toItemResponse(): OrderItemResponse {
    return OrderItemResponse(
        id = id,
        buyerName = buyerName.orAnonymous(),
        amount = amount.toYuanText(),
        status = status.toOrderStatusText(),
        createdAt = createdAt.toSecondText()
    )
}

fun List<OrderEntity>.toItemResponses(): List<OrderItemResponse> {
    return map { it.toItemResponse() }
}

fun main() {
    val orders = listOf(
        OrderEntity(
            id = 1001,
            buyerName = null,
            amount = BigDecimal("19.9"),
            status = "PAID",
            createdAt = LocalDateTime.of(2026, 7, 8, 10, 0, 0)
        ),
        OrderEntity(
            id = 1002,
            buyerName = "Lucy",
            amount = BigDecimal("5"),
            status = "PENDING",
            createdAt = LocalDateTime.of(2026, 7, 8, 11, 30, 0)
        )
    )

    orders.toItemResponses().forEach(::println)
}

Output:

OrderItemResponse(id=1001, buyerName=anonymous customer, amount=¥19.90, status=paid, createdAt=2026-07-08 10:00:00)
OrderItemResponse(id=1002, buyerName=Lucy, amount=¥5.00, status=To be paid, createdAt=2026-07-08 11:30:00)

In this demo, each extension only does one small thing:

String? Responsible for empty names
BigDecimal Responsible amount text
LocalDateTime Responsible time text
String Responsible status text
OrderEntity Responsible for transferring DTO
List<OrderEntity> Responsible for batch conversion

The call site reads straightforward:

orders.toItemResponses()

Some practical rules for writing extensions

Extensions are comfortable to use and usually comply with these rules:

  • The function is short, the behavior is clear, and can be understood at a glance
  • Receiver type and function semantics are strongly related
  • Do not hide network requests, database writes, transactions, and message sending
  • Does not rely on implicit global state
  • The name should be specific to avoid conflicts with the standard library and member functions.
  • Public extensions are placed under clear package names and local extensions are used. private
  • Extended attributes only perform calculations and are not disguised as object fields.
  • Use member functions when polymorphism is required, do not use extension functions

Summarize

The greatest value of Kotlin Extension is to write "small capabilities around a certain type" closer to the calling semantics.

It reduces utility noise and makes formatting, conversion, DSL, and collection helper functions more natural.

But it's not inheritance, it's not overwriting, and it's not runtime patching.

The core rules can be written down into a few sentences:

Extensions are called like members and are essentially static functions.
Member functions take precedence over extension functions
Extensions are statically resolved by declared type, no runtime polymorphism
Extended properties have no behind-the-scenes fields and can only be calculated or forwarded
Nullable receivers can be processed directly null

Really useful extensions are usually small, specific, and close to type. Think of it as a "more natural tool function" rather than a "universal transformer", and the code will be much cleaner.