Kotlin Gotchas

Tricky corners that trip up experienced developers. Each one shows the wrong behavior, why it happens, and the fix.

==/=== data class copy Mutable state Scope functions lateinit Nullable generics Non-local return Coroutine leaks
๐ŸŸฐ

== vs ===

โ„น๏ธ
Kotlin flips Java's operator meanings around. == calls equals() โ€” structural equality, "do these represent the same value." === checks reference identity โ€” "are these literally the same object in memory." Java developers coming to Kotlin sometimes reach for === out of habit, expecting == to behave like Java's reference-comparing ==; it's the opposite.
Two equal-but-distinct objects Gotcha
data class Point(val x: Int, val y: Int)

val a = Point(1, 2)
val b = Point(1, 2)

println(a == b)   // true: equals() compares x and y
println(a === b)  // false: two distinct instances

// Boxed integers outside the cached range (-128..127) show the
// same trap Java has, if you ever do reach for ===
val n1: Int? = 200
val n2: Int? = 200
println(n1 == n2)   // true: structural
println(n1 === n2)  // false: different boxed Integer objects outside the cache
๐Ÿ’ก
Use == for essentially everything โ€” it's what "is this the same value" means in almost every real check you'll write. Reach for === only when identity itself is the question: caching, memoization keys, or verifying a singleton (object) reference really is the one and only instance.
๐Ÿ“‡

data class copy() with var Properties

โ„น๏ธ
Covered in depth in Classes & Objects: a data class generates equals()/hashCode() from its primary-constructor properties. A var property is legal there, but it means the object's hash and equality can change after construction โ€” which is exactly the kind of instability a HashMap or HashSet can't tolerate.
A mutated key vanishes from a HashSet Gotcha
data class Task(val id: Int, var completed: Boolean)

val task = Task(1, completed = false)
val tasks = hashSetOf(task)

println(task in tasks)  // true

task.completed = true       // mutates the var โ€” hashCode() changes as a side effect

println(task in tasks)  // false! the set is still bucketed by the OLD hash
println(tasks.size)     // 1: the object is still physically in there, just unreachable by equals
๐Ÿ’ก
Keep every property on a data class a val. When state genuinely needs to change over time, use copy() to produce a new instance rather than mutating a var in place โ€” that keeps the object's hash and equality stable for its entire lifetime, which is what every hash-based collection assumes.
๐Ÿ”

Mutable State Behind Read-Only Interfaces

โ„น๏ธ
Also covered in Collections: Kotlin's List is read-only, not immutable. The distinction matters most when a List you exposed is secretly backed by a MutableList someone else still holds a reference to.
A "safe" getter that isn't Gotcha
class ShoppingCart {
    private val _items = mutableListOf<String>()

    // Looks safe: callers get a List, not a MutableList
    val items: List<String> get() = _items

    fun add(item: String) = _items.add(item)
}

val cart = ShoppingCart()
val snapshot = cart.items   // looks like a safe read-only reference...
cart.add("apples")

println(snapshot)  // [apples] โ€” "snapshot" was never actually a snapshot;
                     // it's the same list, seen through a narrower type
๐Ÿ’ก
If a caller needs a real point-in-time snapshot, return _items.toList() instead of _items directly โ€” that allocates an independent copy. Returning the read-only view as-is is fine when callers only ever read it immediately and don't hold onto the reference across further mutations of the source.
๐ŸŽฏ

Scope Function Misuse

โ„น๏ธ
The full decision table for let/run/with/apply/also lives in the Scope Functions guide. The two mistakes below are the ones that show up most in real code review.
apply vs let: returning the wrong thing Gotcha
// apply returns the RECEIVER, not the lambda's last expression โ€”
// easy to reach for when you actually wanted let's return value
val length = "hello".apply {
    uppercase()   // this result is discarded!
}
println(length)  // "hello": apply always returns the original receiver

// let returns the lambda's last expression โ€” this is what was meant
val upper = "hello".let {
    it.uppercase()
}
println(upper)  // "HELLO"
Nested it shadows the outer one Gotcha
val users = listOf("Ada", "Grace")
val lengths = listOf(3, 5)

// Both lambdas use `it` implicitly โ€” the inner one silently
// shadows the outer, and this compiles without a warning
users.forEach {
    lengths.filter { it > 3 }.forEach {
        println(it)  // which "it"? the innermost one โ€” easy to misread at a glance
    }
}

// Fix: name at least the outer parameter explicitly
users.forEach { user ->
    lengths.filter { it > 3 }.forEach { len ->
        println("$user: $len")
    }
}
โณ

lateinit Pitfalls

Reading before assignment crashes at runtime, not compile time Gotcha
class ReportView {
    lateinit var title: String

    fun render(): String = "Report: $title"
}

val view = ReportView()
// Forgot to call view.title = "..." before this โ€” compiles fine,
// crashes only when render() is actually called
println(view.render())
// kotlin.UninitializedPropertyAccessException:
//   lateinit property title has not been initialized

// Guard against it explicitly when the assignment isn't guaranteed
// by a framework lifecycle you control
if (view::title.isInitialized) {
    println(view.render())
}
โš ๏ธ
The compiler gives lateinit properties no static guarantee of initialization before use โ€” that's the whole trade-off you're making versus a constructor parameter. Reserve lateinit for properties a well-understood lifecycle (dependency injection, Android's onCreate, test @BeforeEach) genuinely assigns before first use, and prefer a constructor parameter whenever that's actually an option.
๐Ÿงฌ

Nullable Generics: T vs T?

โ„น๏ธ
An unbounded type parameter <T> defaults to an implicit upper bound of Any?, not Any โ€” meaning T can be a nullable type unless you constrain it. This surprises developers used to thinking of a bare type parameter as automatically non-null.
T silently allows null Gotcha
// No bound written means T : Any? implicitly โ€” T can be nullable
class Box<T>(val value: T)

val box: Box<String?> = Box(null)  // compiles fine: T inferred as String?
println(box.value?.length)             // must still null-check to use it

// Constrain T : Any to rule nullable types out entirely
class NonNullBox<T : Any>(val value: T)
// val bad: NonNullBox = NonNullBox(null)  // ERROR: T bound to Any excludes String?
val good = NonNullBox("Ada")  // fine: T inferred as String (non-null)
๐Ÿ’ก
Write <T : Any> explicitly whenever a generic class or function genuinely can't handle a null value of T โ€” a cache, a non-empty container, anything that would misbehave silently on a null element. Don't rely on "I never intended to allow null" as documentation; make the compiler enforce it.
โ†ฉ๏ธ

Non-Local Returns & forEach

โ„น๏ธ
Covered from the mechanism side in Functions & Lambdas: because forEach is inline, a bare return inside its lambda doesn't just skip to the next element โ€” it returns from the entire enclosing function. Developers coming from languages where a callback's return only affects the callback get bitten by this reflexively.
return inside forEach exits the whole function Gotcha
fun findFirstNegative(numbers: List<Int>): Int? {
    numbers.forEach {
        if (it < 0) {
            return it   // returns from findFirstNegative โ€” this one actually works as intended
        }
    }
    return null
}

fun printAllPositive(numbers: List<Int>) {
    numbers.forEach {
        if (it < 0) {
            return       // BUG: exits printAllPositive entirely on the FIRST negative,
        }                     // not just skipping this one element like "continue" would
        println(it)
    }
    println("done")   // never reached if any number was negative
}

// Fix: a labeled return targets only the current lambda invocation
fun printAllPositiveFixed(numbers: List<Int>) {
    numbers.forEach {
        if (it < 0) return@forEach   // skips just this element
        println(it)
    }
    println("done")   // now always reached
}
๐Ÿ’ก
When you actually want "skip this element, keep going," use return@forEach or, often more readably, switch to an ordinary for loop with continue โ€” a real loop makes the control flow visually obvious in a way a labeled return inside a lambda doesn't.
๐ŸŽธ

Elvis with Side Effects

The right side only runs when needed Gotcha
var logCount = 0
fun logAndReturnDefault(): String {
    logCount++
    return "default"
}

val name: String? = "Ada"

// Easy to assume the log call always runs "as part of the expression" โ€”
// it doesn't, because ?: only evaluates its right side when needed
val result = name ?: logAndReturnDefault()
println(logCount)  // 0: name wasn't null, so logAndReturnDefault() never ran

val missing: String? = null
val result2 = missing ?: logAndReturnDefault()
println(logCount)  // 1: this time it did run
๐Ÿ’ก
This laziness is almost always what you want โ€” see Null Safety for why it's a feature, not a bug. It only becomes a gotcha when a side effect on the right side of ?: was assumed to run unconditionally, like a metrics counter or an audit log the author expected to fire on every call regardless of nullness.
๐Ÿ”ข

Integer Overflow

โš ๏ธ
Kotlin's Int arithmetic wraps silently on overflow, same as Java โ€” there's no automatic promotion to a bigger type and no exception thrown by default. A sum that exceeds Int.MAX_VALUE (2,147,483,647) doesn't crash; it wraps around to a negative number, and the bug can hide for a long time if the inputs that trigger it are rare.
Overflow wraps around instead of throwing Gotcha
val max = Int.MAX_VALUE
println(max)          // 2147483647
println(max + 1)      // -2147483648: silently wrapped, no exception

// A realistic trigger: summing a large list of Ints
val largeNumbers = List(3) { Int.MAX_VALUE }
println(largeNumbers.sum())  // wraps to a negative, nonsensical total

// Fixes: use Long for values that can plausibly grow large,
// or use the checked arithmetic functions that throw instead of wrapping
val total: Long = largeNumbers.sumOf { it.toLong() }
println(total)  // 6442450941: correct

val checked = Math.addExact(max, 1)  // throws ArithmeticException on overflow
๐Ÿ’ก
Default to Long for any accumulator, counter, or sum that could plausibly grow large over the program's lifetime (IDs, timestamps in milliseconds, running totals). It costs nothing meaningful in memory or speed on the JVM and removes an entire class of silent-wraparound bugs.
๐Ÿ”€

Coroutine Scope Leaks

โ„น๏ธ
Covered from the design side in Coroutines: structured concurrency exists specifically to prevent this. The gotcha here is what it looks like when a codebase quietly opts out of that protection, usually to work around a compile error rather than as a deliberate choice.
Reaching for GlobalScope to silence a compile error Gotcha
class UserRepository {
    fun refreshInBackground() {
        // No CoroutineScope available in this non-suspend function,
        // so it's tempting to reach for GlobalScope to make it compile
        GlobalScope.launch {
            delay(5000)
            syncWithServer()
        }
        // This coroutine has no parent. Nothing cancels it if the
        // repository, the screen, or the whole feature is torn down
        // first โ€” it keeps running, potentially touching freed state.
    }
}

// Fix: give the class its own scope tied to a real lifecycle,
// and cancel it explicitly when that lifecycle ends
class UserRepositoryFixed(
    private val scope: CoroutineScope
) {
    fun refreshInBackground() {
        scope.launch {
            delay(5000)
            syncWithServer()
        }
    }
}
// scope.cancel() when the repository's owner is done with it โ€”
// now the launched work is provably bounded by a real lifetime
๐Ÿ’ก
Treat "I need GlobalScope to make this compile" as a design smell, not a solution. The fix is almost always to thread a properly-scoped CoroutineScope into whatever class needs to launch work โ€” tied to a ViewModel, a request lifecycle, or an explicit close()/cancel() โ€” so every coroutine you start has a clear owner responsible for stopping it.