Nullable Types and the Safe Call Operator

In Kotlin, every type comes in two flavors: an ordinary type that can never hold null, and a nullable version of that type, written with a trailing ?, that explicitly allows null. This isn’t a runtime convention — it’s baked into the type system, so the compiler refuses to compile code that might dereference a null value without first handling that possibility. The safe call operator (?.) is the everyday tool for working with nullable values: it calls a method or reads a property only if the receiver isn’t null, otherwise it short-circuits to null instead of crashing. Together, nullable types and safe calls are Kotlin’s answer to what Tony Hoare called his “billion-dollar mistake” — the null reference — and they are one of the biggest reasons developers coming from Java find Kotlin such a relief.

Overview: How Nullable Types Work

By default, every type in Kotlin is non-nullable. A variable declared as String is guaranteed by the compiler to never hold null — you simply cannot assign null to it, and the compiler rejects the program if you try. If you need a variable that might legitimately have no value, you opt in explicitly by adding ? to the type: String?. This creates a distinct type from String — a String? cannot be passed anywhere a plain String is expected without first proving it isn’t null.

Under the hood, both String and String? compile to the same JVM bytecode representation (there’s no separate runtime type for nullability on the JVM). The difference lives entirely at compile time: the Kotlin compiler tracks which expressions might be null and statically rejects any attempt to call a member on a nullable expression without a safe operator. This is why null-safety in Kotlin is often described as “compile-time enforced” — the checks happen before your program ever runs, not with a defensive if (x != null) scattered everywhere at runtime.

When you do check a nullable value for null — for example with if (value != null) — the compiler performs what’s called a smart cast: inside the block where you’ve proven the value isn’t null, the compiler treats it as the non-null type automatically, and you can use it exactly like a non-nullable value with no further conversion needed. Smart casts only work for values the compiler can prove won’t change between the check and the use, which is why they apply to local vals and read-only properties, but not to mutable var properties (more on that in Common Mistakes below).

Syntax

The core null-safety syntax has three pieces: declaring a nullable type, safely accessing it, and providing a fallback.

val nullableName: String? = "Kotlin"   // can hold a String or null
val nonNullName: String = "Kotlin"     // can never hold null

val length1 = nullableName?.length     // safe call: null if nullableName is null
val length2 = nullableName?.length ?: 0 // elvis: fall back to 0 if null
val length3 = nullableName!!.length    // not-null assertion: throws if null
Operator Name What it does
? Nullable type marker Appended to a type to allow null, e.g. Int?
?. Safe call Calls a member if the receiver isn’t null; otherwise evaluates to null
?: Elvis operator Supplies a default value when the left-hand expression is null
!! Not-null assertion Forces the type to non-null; throws NullPointerException if the value is actually null
?.let { } Safe call + let Runs a lambda only when the receiver isn’t null, with the value passed in non-null

Examples

Example 1: Basic safe calls

fun main() {
    val name: String? = "Kotlin"
    val nullName: String? = null

    println(name?.length)
    println(nullName?.length)
}

Output:

6
null

The first safe call succeeds because name holds a real string, so ?.length returns its length, 6. The second safe call short-circuits: because nullName is null, Kotlin never attempts to read .length at all, and the whole expression evaluates to null, which println happily prints as the text null.

Example 2: The Elvis operator for fallback values

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

    val greeting: String? = null
    val message = greeting ?: "Hello, stranger!"
    println(message)
}

Output:

Length: 0
Hello, stranger!

The Elvis operator ?: reads as “or else”: if the expression on its left is null, use the expression on its right instead. Combined with a safe call, name?.length ?: 0 means “the length of name, or 0 if there is no name” — and the whole expression has type Int, not Int?, because the Elvis operator guarantees a non-null result whenever its fallback is non-null.

Example 3: A realistic lookup with chained safe calls

data class User(val name: String, val email: String?)

fun findUser(id: Int): User? {
    val users = mapOf(
        1 to User("Alice", "alice@example.com"),
        2 to User("Bob", null)
    )
    return users[id]
}

fun main() {
    val ids = listOf(1, 2, 3)
    for (id in ids) {
        val user = findUser(id)
        val domain = user?.email?.substringAfter("@")
        println("User $id domain: ${domain ?: \"no email on file\"}")
    }

    findUser(1)?.let { u ->
        println("Found user: ${u.name}")
    }
}

Output:

User 1 domain: example.com
User 2 domain: no email on file
User 3 domain: no email on file
Found user: Alice

This mirrors a very common real-world shape: a lookup that might not find anything (findUser returns User?), feeding into a field that itself might be absent (email: String?). The chain user?.email?.substringAfter("@") only reaches substringAfter if both user and email are non-null; for id 2 the user exists but has no email, and for id 3 there’s no user at all — both cases flow through to the same null, and the Elvis operator turns that into a friendly message. Notice too that User is a data class: Kotlin generates equals(), hashCode(), toString(), and copy() for it automatically, which is why we can construct and compare User values with almost no boilerplate.

How It Works Step by Step

Walking through Example 3 for id = 2: findUser(2) looks up the map and returns the User("Bob", null) wrapped as a non-null result, since the key exists. The safe call user?.email sees that user is not null, so it proceeds to read .email, which is null for Bob. The next safe call in the chain, ?.substringAfter("@"), now sees a null receiver, so it skips the call entirely and the whole chained expression evaluates to null without ever risking a NullPointerException. That null flows into the Elvis operator inside the string template, which substitutes the fallback text. For id = 3, the very first step differs: users[3] finds no matching key, so findUser itself returns null, and the first safe call in the chain short-circuits immediately — but the result is the same fallback message, because a safe call chain stops at whichever link first hits null and propagates null all the way to the end.

Common Mistakes

Mistake 1: Reaching for !! instead of handling null

The not-null assertion operator !! tells the compiler “trust me, this is never null” — and if you’re wrong, it throws a NullPointerException at runtime, which is exactly the crash Kotlin’s type system exists to prevent. New Kotlin developers often reach for !! just to make the compiler stop complaining, which quietly reintroduces Java’s null-pointer risk.

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

This compiles fine, but crashes as soon as it runs, because input really is null when !! forces it to be treated as non-null. The fix is to handle the null case explicitly with a safe call and a fallback:

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

Now there’s no crash: when input is null, length simply becomes 0. As a rule of thumb, reserve !! for situations where a null at that point genuinely indicates a bug in your own program (and you want a loud crash to reveal it), not for values that can legitimately be absent.

Mistake 2: Expecting a smart cast on a mutable property

Smart casts only work when the compiler can guarantee the value can’t change between the null check and the use. A var property on a class doesn’t meet that bar — another thread, or a custom getter, could change it in between — so the compiler refuses to smart-cast it, even right after a null check.

class Container {
    var value: String? = "hello"
}

fun printLength(c: Container) {
    if (c.value != null) {
        println(c.value.length) // Error: smart cast to 'String' is impossible, because 'c.value' is a mutable property that could change
    }
}

The fix is to copy the property into a local val first; a local val can’t be reassigned out from under you, so the compiler can safely smart-cast it after the check:

class Container {
    var value: String? = "hello"
}

fun printLength(c: Container) {
    val local = c.value
    if (local != null) {
        println(local.length)
    } else {
        println("no value")
    }
}

fun main() {
    val container = Container()
    printLength(container)
}

Output:

5

Copying c.value into local takes a stable snapshot, so once the null check passes, local is smart-cast to String for the rest of the block, and local.length compiles and prints 5.

Best Practices

  • Prefer ?. and ?: over !! in normal code paths — save !! for cases where a null there truly signals a bug you want to fail loudly on.
  • Model genuine absence with a nullable type (String?) rather than sentinel values like empty strings or -1.
  • Use ?.let { } when you need to run several statements only in the non-null case, instead of nesting an if (x != null) block.
  • Assign a nullable expression (especially a var property or function call result) to a local val before checking it, so the compiler can smart-cast it afterward.
  • Avoid long chains like a?.b?.c?.d in production code; extract a well-named helper function so a reader doesn’t have to trace every possible null point.
  • Don’t overload the meaning of null to mean both “absent” and “error” — use exceptions or a result type for genuine failures, and reserve null for values that legitimately don’t exist.

Practice Exercises

  • Write a function fun firstVowel(s: String?): Char? that returns the first vowel found in s, or null if s is null or contains no vowels. Do not use !! anywhere.
  • Given val scores = mapOf("Ann" to 92, "Bo" to null, "Cy" to 78) where a null score means the student was absent, print a line per student reading either "Ann: 92" or "Bo: absent" using the Elvis operator.
  • Take a snippet that uses !! three separate times to read a nested nullable property chain, and rewrite it using only ?., ?:, and ?.let so it no longer contains a single !!.

Summary

  • Every Kotlin type has a non-nullable form by default and an explicit nullable form written with a trailing ?, such as String vs String?.
  • The safe call operator ?. calls a member only when the receiver isn’t null, otherwise the whole expression becomes null — and this short-circuits through chained calls.
  • The Elvis operator ?: supplies a fallback value for the null case, turning a nullable result into a guaranteed non-null one.
  • The not-null assertion !! throws NullPointerException at runtime if the value is actually null; use it sparingly and only when a null there means your program has a bug.
  • Smart casts let the compiler treat a checked nullable local val as non-null automatically, but this doesn’t extend to mutable var properties — copy them to a local val first.
  • ?.let { } is the idiomatic way to run a block of code only when a nullable value is present.