Common Kotlin Mistakes

Kotlin’s compiler catches many bugs that would slip past in Java or JavaScript, but that safety net has edges — and most real-world Kotlin bugs happen exactly at those edges. This lesson walks through the mistakes that show up over and over in code review: misusing the not-null assertion operator, writing a when expression that looks complete but isn’t exhaustive, mixing up structural and referential equality, and misunderstanding what val actually protects. Each mistake is shown broken first, then fixed, with the reasoning behind the fix, so you recognize the pattern instantly the next time you see it.

Overview: Why These Mistakes Keep Happening

Most Kotlin mistakes aren’t syntax errors — they’re places where a Java habit, a rushed shortcut, or an incomplete mental model produces code that either fails to compile for a reason you don’t understand, or worse, compiles fine and does the wrong thing at runtime. Kotlin’s null-safety system, for example, forces you to decide upfront how to handle a possibly-absent value: the compiler will not let a String? flow into a slot that expects a non-null String without you writing a safe call (?.), an Elvis operator (?:), an explicit null check, or the not-null assertion (!!). Reaching for !! because it’s the shortest option defeats the entire point of the feature — it converts a compile-time guarantee back into a runtime crash risk, the exact failure mode Kotlin was designed to eliminate.

Other mistakes come from assumptions that don’t transfer from other languages. In Java, == on objects checks reference identity; in Kotlin, == calls equals() by default, so it checks structural equality. A val declaration looks like Java’s final, but it only locks the reference, not the object it points to — a val holding a MutableList can still grow, shrink, or change every element. And a when expression that looks complete to a human reader can be missing a branch the compiler required, because exhaustiveness is enforced only when the when is used as an expression (its result is assigned or returned), not when it’s used as a plain statement.

The common thread: the compiler is stricter and more helpful than Java’s, but only if you use its features the way they’re intended. Every mistake below either bypasses a safety feature (!!), misunderstands what a feature actually checks (== vs ===, val), or misses a rule the compiler enforces silently (exhaustive when).

Syntax: The Operators at the Center of These Mistakes

Four operators and one keyword account for most Kotlin mistakes. Knowing exactly what each one does — and does not do — removes most of the confusion:

nullableType:   Type?              // may hold null; Type alone never can
safeCall:       value?.member      // evaluates to null if value is null
elvisOperator:  value ?: fallback  // supplies fallback when the left side is null
notNullAssert:  value!!            // throws NullPointerException if value is null
structuralEq:   a == b             // calls a.equals(b)
referentialEq:  a === b            // true only if a and b are the same object
Operator / Keyword Meaning When to use it
?. Safe call — short-circuits to null instead of throwing Any time you access a member of a nullable value
?: Elvis operator — supplies a default for the null case Right after a safe call, or any expression that might be null
!! Not-null assertion — converts null into a thrown exception Rarely; only when null there would be a genuine programmer error
== Structural equality via equals() Comparing values — the default choice almost always
=== Referential equality (same object) Checking identity, e.g. singleton comparisons
when (as expression) Must cover every possible input or it won’t compile Mapping a sealed type or enum to a value you return or assign

Examples

Example 1: A Safe Call and Elvis Operator Instead of !!

This function needs the length of a possibly-null string. Instead of asserting non-null with !!, it chains a safe call with an Elvis operator to supply a sentinel value.

fun describeLength(text: String?): String {
    val length = text?.length ?: -1
    return "Length: $length"
}

fun main() {
    println(describeLength("Kotlin"))
    println(describeLength(null))
}

Output:

Length: 6
Length: -1

text?.length evaluates to Int? — either the real length or null. The Elvis operator then supplies -1 only when the left side is null, so the function never throws no matter what it’s given.

Example 2: Structural Equality (==) vs Referential Equality (===)

a and b hold two separately constructed but value-identical Point instances; c is a second reference to a‘s own object.

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

fun main() {
    val a = Point(1, 2)
    val b = Point(1, 2)
    val c = a

    println(a == b)
    println(a === b)
    println(a === c)
}

Output:

true
false
true

a == b is true because the generated equals() compares x and y. a === b is false because they are two distinct objects in memory, even with identical fields. a === c is true because c was assigned directly from a — it’s the same object.

Example 3: An Exhaustive when Over a Sealed Class

Shape is sealed, so the compiler knows its complete set of subtypes and can verify the when below handles every one of them.

sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()

fun area(shape: Shape): Double = when (shape) {
    is Circle -> Math.PI * shape.radius * shape.radius
    is Rectangle -> shape.width * shape.height
}

fun main() {
    val shapes = listOf(Circle(2.0), Rectangle(3.0, 4.0))
    for (shape in shapes) {
        println("Area: ${"%.2f".format(area(shape))}")
    }
}

Output:

Area: 12.57
Area: 12.00

Because area‘s body is a when expression (its value is returned), the compiler requires every Shape subtype to be handled. If a third subtype were added to Shape without updating area, this file would stop compiling until the new branch was added.

How It Works Step by Step

When the compiler sees if (text != null), it doesn’t just run your check at runtime and hope for the best — it performs static analysis called smart-casting. Inside the true branch, the compiler treats text as the non-null type String for the rest of that scope, because it has proven no other code can invalidate the check before you use text again. That’s why a val parameter can be null-checked once and then used freely afterward with no ?. or !! needed.

fun printUpper(text: String?) {
    if (text != null) {
        println(text.uppercase())
    } else {
        println("null value")
    }
}

fun main() {
    printUpper("kotlin")
    printUpper(null)
}

Output:

KOTLIN
null value

For when, the compiler performs a comparable static check whenever the when is used as an expression. If the subject is an enum, it walks every declared entry; if it’s a sealed class or sealed interface, it walks every direct subtype declared in the same module. If your branches don’t cover all of them and there is no else, compilation fails and names exactly which branches are missing. This means adding a new subtype to a sealed hierarchy breaks every when that maps over it until you handle the new case — a whole category of bug caught at the moment you introduce it rather than the moment someone hits the missing branch in production.

For data classes, the compiler generates equals() and hashCode() from the properties declared in the primary constructor, which is exactly why == on two data class instances compares field values instead of memory addresses. For val, the compiler enforces only that the variable is assigned exactly once — it inserts no protection whatsoever around the object graph that variable points to, which is why the mutable-collection mistake below is possible.

Common Mistakes

Mistake 1: Reaching for !! Instead of Proper Null Handling

This compiles, because !! is type-correct, but it crashes the moment it runs against a null value:

fun main() {
    val name: String? = null
    println(name!!.length)
}

Output:

Throws a NullPointerException at runtime — !! asserts name is non-null immediately before .length is accessed, and since name is null the assertion fails and the program crashes before printing anything.

The fix replaces the assertion with a safe call and a default, so the null case is handled instead of merely deferred to a crash:

fun main() {
    val name: String? = null
    val length = name?.length ?: 0
    println(length)
}

Output:

0

Mistake 2: Assuming when Is Exhaustive When It Isn’t

This looks reasonable but omits YELLOW, so it fails to compile — the error names the missing branch:

enum class TrafficLight { RED, YELLOW, GREEN }

fun action(light: TrafficLight): String = when (light) {
    TrafficLight.RED -> "Stop"
    TrafficLight.GREEN -> "Go"
}

Adding the missing case fixes it — and if a fourth color were ever added to the enum, this would fail to compile again until handled, which is the exhaustiveness check working as intended:

enum class TrafficLight { RED, YELLOW, GREEN }

fun action(light: TrafficLight): String = when (light) {
    TrafficLight.RED -> "Stop"
    TrafficLight.YELLOW -> "Slow down"
    TrafficLight.GREEN -> "Go"
}

fun main() {
    println(action(TrafficLight.YELLOW))
}

Output:

Slow down

Mistake 3: Using === When You Mean ==

This compiles and runs, but it’s almost never what the author intended — two separately constructed users with identical data are treated as different:

data class User(val id: Int, val name: String)

fun main() {
    val u1 = User(1, "Ana")
    val u2 = User(1, "Ana")
    if (u1 === u2) {
        println("Same user")
    } else {
        println("Different user")
    }
}

Output:

Different user

Switching to == compares the underlying fields via the generated equals(), which is what “same user” almost always means:

data class User(val id: Int, val name: String)

fun main() {
    val u1 = User(1, "Ana")
    val u2 = User(1, "Ana")
    if (u1 == u2) {
        println("Same user")
    } else {
        println("Different user")
    }
}

Output:

Same user

Mistake 4: Believing val Makes a Collection Immutable

val scores looks locked down, but passing a MutableList reference into a function that mutates it changes the same list everyone else sees:

fun addBonus(scores: MutableList<Int>) {
    scores.add(100)
}

fun main() {
    val scores = mutableListOf(10, 20, 30)
    addBonus(scores)
    println(scores)
}

Output:

[10, 20, 30, 100]

val only prevents reassigning the scores variable to a different list — it says nothing about the list’s contents. The fix is to use the read-only List type at the boundary and build new lists with + instead of mutating in place:

fun withBonus(scores: List<Int>): List<Int> = scores + 100

fun main() {
    val scores = listOf(10, 20, 30)
    val updated = withBonus(scores)
    println(scores)
    println(updated)
}

Output:

[10, 20, 30]
[10, 20, 30, 100]

Mistake 5: Shadowing Variables Without Realizing It

This compiles cleanly, but the inner value creates a brand-new binding scoped to the run block rather than modifying the outer one:

fun main() {
    val value = 10
    run {
        val value = 20
        println("Inner: $value")
    }
    println("Outer: $value")
}

Output:

Inner: 20
Outer: 10

If you expected the outer value to become 20 after the block ran, you’d be wrong — and because both variables share a name, the bug is easy to miss in a longer function with nested lambdas. Renaming one of them makes the intent (and any mistake) immediately visible.

Best Practices

  • Avoid !! in application code; prefer ?., ?:, or an explicit if (x != null) check that lets the compiler smart-cast for you.
  • When a when is meant to be exhaustive, let it be — over a sealed class or enum with no else branch, so adding a new case forces you to update every place that handles it.
  • Default to == for comparisons; reach for === only when you specifically need identity, such as checking against a singleton.
  • Expose the read-only List/Map/Set interfaces in function signatures and return types, and reserve MutableList and friends for the few places that genuinely need to mutate.
  • Give shadowed and inner-scope variables distinct names, especially inside lambdas passed to let, run, or also, where it’s easy to shadow a property or parameter by accident.
  • Let data classes generate equals, hashCode, and copy for you instead of writing them by hand — hand-written versions are a common source of bugs once a property is added and the manual code isn’t updated to match.

Practice Exercises

  1. Write a function firstInitial(name: String?): Char? that returns the first character of name uppercased, or null if name is null or empty — without using !!.
  2. Define a sealed class PaymentResult with subtypes Success(val amount: Double), Declined(val reason: String), and object Pending. Write a when expression that returns a human-readable String for each case, then confirm the compiler rejects it if you comment out one branch.
  3. Given val original = mutableListOf("a", "b", "c"), write a function that returns a new, independent list with an extra element appended, without modifying original. Print both lists to prove original is unchanged.

Summary

  • !! converts Kotlin’s compile-time null safety into a runtime crash risk — prefer ?., ?:, or a null check instead.
  • A when used as an expression must be exhaustive; the compiler checks every enum entry or sealed subtype and rejects incomplete ones.
  • == calls equals() for structural equality; === checks whether two references point to the same object.
  • val only locks the variable, not the object it refers to — a val holding a mutable collection can still have its contents changed.
  • Shadowed variables create a new binding in the inner scope rather than modifying the outer one; give them distinct names to avoid confusion.
  • Data classes auto-generate equals, hashCode, toString, copy, and componentN — let the compiler do this work instead of writing it by hand.