== vs ===
structural ยท referentialKotlin 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
shallow copy ยท broken hashCodeCovered 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
List vs immutable ยท false confidenceAlso 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
let ยท apply ยท nested it ยท wrong returnThe 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
UninitializedPropertyAccessException
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?
Any? upper bound ยท unexpected nullAn 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
inline ยท return@label ยท silent early exitCovered 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
?: ยท lazy evaluation ยท order of operations
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
Int.MAX_VALUE ยท silent wraparoundKotlin'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
GlobalScope ยท unstructured ยท outlives callerCovered 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.