Null Safety Explained

In Java, any object reference can be null, and forgetting to check for it produces a NullPointerException at the worst possible moment, usually in production. Kotlin bakes the possibility of null directly into its type system, so the compiler forces you to handle nullability where you write the code instead of where a user finds it. This single design choice removes an entire category of runtime crashes and is one of the biggest reasons developers describe Kotlin as "Java, but safer." This lesson covers every tool Kotlin gives you for working with nullable values: the nullable type itself, the safe call and Elvis operators, smart casts, the not-null assertion, and the mistakes that trip up newcomers.

Overview: How Null Safety Works

Every type in Kotlin comes in two flavors: a non-null version, like String, and a nullable version, written with a trailing question mark, like String?. These are genuinely different types as far as the compiler is concerned. A String variable can never hold null; assigning null to it is a compile-time error, not a runtime surprise. A String? variable can hold either a real String or null, and the compiler will not let you call a member on it, such as .length, without first proving that it isn’t null.

This proof can come from a few places: an explicit if (x != null) check, the safe-call operator ?., the Elvis operator ?:, or the not-null assertion !! (which trades the compile-time guarantee for a runtime crash). Whenever the compiler can statically verify that a nullable value cannot be null at a particular point in the code, it performs a smart cast: inside that scope, the value is treated as the non-null type without you casting it yourself. Smart casts are one of Kotlin’s quieter but most useful features — once you write if (x != null), every line after it in that branch can use x as if it were declared non-null.

Smart casts only work when the compiler can guarantee the value hasn’t changed between the check and the use — a local val, or a local var that a lambda can’t capture concurrently, satisfies this. A var property of a class does not, because another thread or a custom getter could change it in between, so the compiler refuses the smart cast (see Common Mistakes). There is one more wrinkle worth knowing about: when Kotlin calls into Java code, the compiler cannot see whether a Java method can return null, so it treats the result as a platform type (shown as String! in tooling), which you can treat as either nullable or non-null at your own risk. That topic is covered fully in the Kotlin/Java interop lesson, but it’s worth remembering that null-safety guarantees stop at the interop boundary.

Syntax

Kotlin’s null-safety toolkit is small but covers every situation you’ll run into. The table below is the reference; the Examples section shows each piece in a realistic program.

Syntax Name Meaning
T? Nullable type Marks a type as allowed to hold null, e.g. String?
?. Safe call Calls a member only if the receiver isn’t null; otherwise the whole expression is null
?: Elvis operator Supplies a default value (or a return/throw) when the left side is null
!! Not-null assertion Converts T? to T, throwing NullPointerException if the value is null
?.let { } Safe call + scope function Runs the block only when the receiver is non-null, passed in as it
as? Safe cast Casts to a type, producing null instead of throwing on failure
lateinit var Deferred initialization A non-null var assigned later; throws UninitializedPropertyAccessException if read too early

Examples

Example 1: Declaring a nullable variable and using a safe call

fun main() {
    var name: String? = "Kotlin"
    println(name?.length)
    name = null
    println(name?.length)
}

Output:

6
null

name?.length is a safe call: if name is not null, it behaves exactly like name.length; if name is null, the whole expression evaluates to null instead of throwing. Because the result of a safe call is itself nullable (Int? here), println prints the literal text null when there’s nothing there — no exception is ever thrown.

Example 2: Falling back with the Elvis operator

fun describeLength(text: String?): Int {
    return text?.length ?: -1
}

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

Output:

5
-1

The Elvis operator ?: supplies a default when the left-hand side is null. text?.length ?: -1 reads as "the length of text, or -1 if text is null." The right side of ?: is only evaluated when needed, so you can also use it to return or throw from a function early, e.g. val x = maybeNull ?: return.

Example 3: Nullable properties in a data class

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

fun sendWelcomeEmail(user: User) {
    val email = user.email
    if (email != null) {
        println("Sending welcome email to $email")
    } else {
        println("No email on file for ${user.name}")
    }
}

fun main() {
    val alice = User("Alice", "alice@example.com")
    val bob = User("Bob", null)
    sendWelcomeEmail(alice)
    sendWelcomeEmail(bob)

    alice.email?.let { println("Length of email: ${it.length}") }
    bob.email?.let { println("Length of email: ${it.length}") }
}

Output:

Sending welcome email to alice@example.com
No email on file for Bob
Length of email: 17

This example models a realistic situation: a User whose email is optional. Because email is typed String?, every place that reads it must account for the possibility that it’s missing. sendWelcomeEmail uses an explicit if (email != null) check with smart casting; the calls at the bottom use ?.let { }, which runs the lambda only when the receiver isn’t null, passing it in as it. Both styles compile to the same idea — "do this only if there’s a value" — and which reads better depends on how much logic sits inside the block. Notice bob.email?.let { ... } prints nothing at all: the lambda simply never runs.

How It Works Step by Step

It helps to see exactly what the compiler and runtime do at each stage of a null-safety check:

  1. Type checking at compile time. The compiler tracks whether every expression’s type is nullable or non-null. Passing a String? anywhere a String is expected — a function parameter, a val declaration, a return type — is rejected before the code ever runs.
  2. Safe-call evaluation. A chain like a?.b?.c is evaluated left to right and short-circuits: if a is null, neither b nor c is touched, and the whole expression is null. No exception is thrown at any point.
  3. Smart-cast narrowing. After a null check, the compiler narrows the static type inside the surviving branch — x goes from String? to String for the rest of that scope. This is purely a compile-time bookkeeping trick; no cast instruction exists in the compiled bytecode.
  4. Elvis evaluation. a ?: b evaluates a first; only if it’s null does it evaluate and return b. This makes it safe to put an expensive computation, a throw, or a return on the right-hand side without paying for it on the non-null path.
  5. Not-null assertion. x!! compiles to a runtime null check followed by an unconditional throw of NullPointerException if the check fails — the one place Kotlin reintroduces Java’s original behavior, on purpose, as an explicit opt-in.

Common Mistakes

Mistake 1: Reaching for !! instead of handling null

The not-null assertion operator feels like a quick way to silence the compiler, but it just moves the crash from compile time to runtime — exactly the bug null safety exists to prevent.

fun printLength(text: String?) {
    println(text!!.length)
}

fun main() {
    printLength(null)
}

Output:

Prints nothing to standard output. The program crashes with an uncaught NullPointerException because text is null and !! asserts it is non-null.

Calling text!!.length throws the moment text is null — nothing is printed first. Prefer a safe call with a sensible default:

fun printLength(text: String?) {
    val length = text?.length ?: 0
    println(length)
}

fun main() {
    printLength(null)
    printLength("Kotlin")
}

Output:

0
6

Now a null input produces 0 instead of crashing the program.

Mistake 2: Expecting a smart cast on a mutable property

Smart casts require the compiler to prove a value can’t change between the check and the use. A var property of a class can change through another thread or a custom setter at any moment, so the compiler refuses to smart-cast it, even right after a null check:

class Box {
    var value: String? = null

    fun printLength() {
        if (value != null) {
            // Compiler error: Smart cast to 'String' is impossible, because
            // 'value' is a mutable property that could have been changed
            // by this time
            println(value.length)
        }
    }
}

The fix is to copy the property into a local val first. A local val can’t be reassigned, so the compiler can safely narrow its type:

class Box {
    var value: String? = null

    fun printLength() {
        val current = value
        if (current != null) {
            println(current.length)
        }
    }
}

fun main() {
    val box = Box()
    box.value = "Kotlin"
    box.printLength()
}

Output:

6

Mistake 3: Treating lateinit as a way to avoid thinking about null

lateinit var lets you declare a non-null property without an initial value, which is convenient for things like dependency injection or values a framework sets up later. But reading it before it’s assigned throws UninitializedPropertyAccessException — a different exception from NullPointerException, but the same underlying mistake of using a value before it exists:

class Config {
    lateinit var name: String

    fun printName() {
        println(name)
    }
}

fun main() {
    val config = Config()
    config.printName()
}

Output:

Prints nothing. The program crashes with an uncaught kotlin.UninitializedPropertyAccessException because name is read before it is assigned.

Make sure every code path assigns the property before anything reads it:

class Config {
    lateinit var name: String

    fun printName() {
        println(name)
    }
}

fun main() {
    val config = Config()
    config.name = "Production"
    config.printName()
}

Output:

Production

Best Practices

  • Prefer ?. and ?: over !! almost everywhere; treat !! as a last resort and a code smell when you see it in review.
  • Model "absence" with a nullable type instead of a sentinel value like -1 or an empty string — it forces every caller to handle the missing case explicitly.
  • Use ?.let { } to run code only when a value is present instead of nesting an if block.
  • If you need a smart cast on a mutable class property, copy it into a local val first, then check that.
  • Reserve lateinit for properties genuinely guaranteed to be set before use (dependency injection, test setup, Android view binding), not as a shortcut around a nullable type.
  • Use requireNotNull() or checkNotNull() instead of a bare !! when you do need to fail fast — they let you attach a clear error message.
  • At Java interop boundaries, add an explicit null check on platform types (String!) rather than trusting them; the Kotlin compiler cannot verify them for you.

Practice Exercises

  1. Write fun firstCharOrDefault(text: String?, default: Char): Char that returns the first character of text, or default if text is null or empty, using only safe calls and the Elvis operator (no !!). It should return 'K' for ("Kotlin", 'X') and 'X' for both (null, 'X') and ("", 'X').
  2. Given data class Address(val city: String?) and data class Person(val name: String, val address: Address?), write a function that prints a person’s city, or "Unknown city" if either the address or the city is missing, using a single chained safe call.
  3. Take a function that calls text!!.uppercase() and rewrite it so it never throws: return an empty string when text is null, using ?.let or ?:. Test it with both a null and a non-null argument and check the output matches what you expect.

Summary

  • Kotlin types are non-null by default; adding ? creates a distinct nullable type, and the compiler enforces the difference everywhere.
  • ?. safely navigates a chain of nullable values, short-circuiting to null instead of throwing.
  • ?: (the Elvis operator) supplies a fallback, a return, or a throw when the left side is null, and only evaluates its right side when needed.
  • Smart casts let you drop the ? after a null check, but only for values the compiler can prove won’t change — not mutable class properties.
  • !! converts a nullable value to non-null at the cost of a potential runtime NullPointerException; use it sparingly and deliberately.
  • lateinit defers initialization of a non-null var and throws UninitializedPropertyAccessException if read too early — it is not a substitute for nullable typing.
  • Design with nullable types and safe operators instead of sentinel values or defensive !! checks; the compiler will do the checking for you.