Kotlin Null Safety: Complete Guide to Nullable Types and Safe Calls

How Kotlin moves null checking from runtime to compile time — nullable types, safe calls, the Elvis operator, smart casts, and the specific places where a NullPointerException can still occur.

Kotlin's headline safety feature is that nullability is part of the type system. String and String? are different types, and the compiler refuses any operation on the nullable one that would fail if the value were absent.

The practical effect is that the most common runtime error in JVM languages becomes a compile error. The important nuance — covered at the end — is that this guarantee has holes where Kotlin meets Java, and knowing where they are is part of using the feature properly.

The Question Mark Is Part of the Type

A type without ? cannot hold null, and the compiler enforces that at every assignment. A type with ? can, and the compiler then refuses direct member access until you prove the value is present.

Kotlin
The two types are genuinely distinct.
// Non-nullable: cannot ever hold null
var name: String = "Ada"
// Error: Null can not be a value of a non-null type String
// name = null

// Nullable: may hold null
var nickname: String? = "Addy"
nickname = null          // fine

// Direct access on a nullable type is rejected
// Error: Only safe (?.) or non-null asserted (!!.) calls are allowed
// println(nickname.length)

// A non-nullable type is not assignable from a nullable one
val maybe: String? = "hello"
// Error: Type mismatch
// val definite: String = maybe

// The reverse is always safe
val definite: String = "hello"
val widened: String? = definite   // fine

This distinction propagates through generics too: List<String>, List<String?> and List<String>? are three different types — a list of strings, a list that may contain nulls, and a list that may itself be null.

Working with Nullable Values Without Branching

The safe call ?. evaluates to null instead of throwing when the receiver is null. The Elvis operator ?: supplies a fallback for a null result. Together they replace most explicit null checks.

Kotlin
Safe calls, chaining, and Elvis fallbacks.
data class Address(val city: String?, val postcode: String?)
data class Customer(val name: String, val address: Address?)
data class Order(val id: Int, val customer: Customer?)

val order: Order? = loadOrder()

// Safe call: null if the receiver is null
val length: Int? = order?.customer?.name?.length

// The whole chain short-circuits on the first null —
// no nested if statements required
val city: String? = order?.customer?.address?.city

// Elvis: provide a default
val cityOrDefault: String = order?.customer?.address?.city ?: "Unknown"

// Elvis with an early return — a very common guard pattern
fun shipLabel(order: Order?): String {
    val customer = order?.customer ?: return "No customer"
    val city = customer.address?.city ?: return "No address"
    return "${customer.name}, $city"
}

// Elvis with throw for genuinely invalid state
fun requireCity(order: Order): String =
    order.customer?.address?.city
        ?: throw IllegalStateException("Order ${order.id} has no city")

// Safe call on a collection operation
val names: List<String> = order?.customer?.let { listOf(it.name) } ?: emptyList()
Note: ?. on an assignment is also valid: person?.name = "x" performs the assignment only if person is non-null, rather than throwing.

When the Compiler Tracks Your Checks

After a null check, the compiler treats the value as non-nullable within that scope. No cast or unwrap is needed — this is the same control-flow analysis that powers type narrowing in other statically typed languages.

Kotlin
Smart casts after a check, and where they stop working.
fun describe(value: String?) {
    if (value != null) {
        // Smart cast to String — direct access is allowed here
        println(value.length)
        println(value.uppercase())
    }
}

// Works with early return too
fun describeOrBail(value: String?) {
    if (value == null) return
    println(value.length)        // smart cast for the rest of the function
}

// Smart casts apply to type checks as well
fun area(shape: Any): Double = when (shape) {
    is Circle -> Math.PI * shape.radius * shape.radius  // smart cast
    is Rect   -> shape.width * shape.height
    else      -> 0.0
}

// A var property CANNOT be smart cast: another thread, or a custom
// getter, could change it between the check and the use.
class Holder {
    var value: String? = null

    fun broken() {
        if (value != null) {
            // Error: Smart cast to 'String' is impossible,
            // because 'value' is a mutable property
            // println(value.length)

            // Fix: copy into a local val
            val local = value
            if (local != null) println(local.length)
        }
    }
}
Note: Smart casts require the compiler to prove the value cannot change between the check and the use. That holds for val locals and immutable properties, but not for var properties, open properties, or properties with custom getters.

let, run, also and takeIf

Combined with a safe call, the scope functions run a block only when a value is present, which often reads better than an if and keeps the non-null value scoped to where it is used.

Kotlin
The idiomatic nullable patterns.
val email: String? = user.email

// let — run a block only if non-null; `it` is the non-null value
email?.let { addr ->
    sendWelcome(addr)        // addr: String, not String?
}

// Transform and fall back in one expression
val domain: String = email?.let { it.substringAfter('@') } ?: "unknown"

// also — side effect, returns the original value (good for logging)
val saved = user.also { log.info("saving ${it.id}") }.let(repo::save)

// run — like let but with `this` as the receiver
val summary = user.run { "$name <$email>" }

// takeIf — null unless the predicate holds; pairs well with Elvis
val validEmail = email?.takeIf { it.contains('@') } ?: return

// apply — configure and return the receiver
val request = Request().apply {
    url = "https://api.example.com"
    timeout = 30
}
FunctionReceiver asReturnsTypical use
letitBlock resultTransform a nullable value
runthisBlock resultCompute from several members
alsoitThe receiverLogging, side effects in a chain
applythisThe receiverConfiguring a new object
takeIfitReceiver or nullTurn a condition into a nullable
Note: Avoid chaining ?.let blocks several levels deep — nested scope functions become harder to read than the equivalent Elvis-with-early-return guards. Prefer the guard style for multiple preconditions.

The Four Remaining Holes

Kotlin's guarantee is strong but not absolute. There are exactly four realistic ways to see an NPE, and all of them involve stepping outside the type system deliberately or crossing into Java.

1. The Not-Null Assertion

Kotlin
!! converts a nullable to non-nullable, throwing if it is null.
val name: String? = null

// Throws NullPointerException — this is !! doing exactly what it says
// val length = name!!.length

// Almost always there is a better option:
val safe1 = name?.length ?: 0
val safe2 = name ?: error("name was required but missing")

// If you must assert, assert once and explain why
val config = loadConfig()
    ?: error("config must be initialised before first use")

Treat every !! as a comment reading "I am certain this is non-null". Where that certainty comes from program structure rather than a check, prefer ?: with error() — it produces a message explaining what went wrong instead of a bare NPE.

2. lateinit Accessed Too Early

Kotlin
lateinit defers initialisation and throws if accessed before it.
class Service {
    // Promises: non-null, but assigned after construction
    private lateinit var client: HttpClient

    fun init() {
        client = HttpClient()
    }

    fun call() {
        // UninitializedPropertyAccessException if init() has not run
        client.get("/health")
    }

    fun callSafely() {
        if (::client.isInitialized) {
            client.get("/health")
        }
    }
}

// Often a lazy delegate is better: initialised on first access,
// thread-safe by default, and genuinely non-null.
class BetterService {
    private val client: HttpClient by lazy { HttpClient() }
}
Note: lateinit cannot be used with primitive types or val. Where the value can be computed on demand, by lazy is both safer and simpler.

3. Platform Types from Java

Java has no nullability in its type system, so Kotlin treats values returned from Java as platform types, written String!. These bypass the null checks entirely — the compiler allows direct access and an NPE surfaces at runtime.

Kotlin
Java interop is the main source of surprise nulls.
// Java: public String getName() { return null; }

// Kotlin sees String! — a platform type, checks relaxed
val name = javaObject.name     // inferred as String!

// Compiles, then throws at runtime if Java returned null
// println(name.length)

// Fix: declare the nullability you actually expect
val safe: String? = javaObject.name
println(safe?.length ?: 0)

// On the Java side, annotate so Kotlin can see the intent:
//   @Nullable String getName()   -> String?
//   @NotNull  String getName()   -> String

This is why annotating Java APIs with @Nullable and @NotNull matters: it is the only way the Kotlin compiler can enforce anything across the boundary.

4. Leaking this During Construction

If a constructor passes this to code that reads a property not yet initialised, that property is observed as null even though its type says otherwise. It is rare, but it is a genuine hole.

• String and String? are different types — the ? is not documentation.
• Chain with ?. and supply fallbacks with ?: instead of nested if checks.
• Copy a var into a local val to enable a smart cast.
• Use ?.let for present-value blocks; guard clauses for several preconditions.
• Prefer ?: error("why") over !! so failures explain themselves.
• Favour by lazy over lateinit when the value can be computed on demand.
• Annotate Java APIs with @Nullable/@NotNull — platform types skip all checks.

Summary

Kotlin eliminates the ordinary NullPointerException by making nullability a property of types rather than a runtime condition. Safe calls, the Elvis operator and smart casts then make working with genuinely optional values concise rather than defensive.

The guarantee holds as long as you stay inside it. The holes are few and well-defined: !!, uninitialised lateinit, platform types from unannotated Java, and leaking this during construction.

In practice, writing nullability honestly in signatures and reserving !! for cases that genuinely cannot be expressed otherwise is enough to keep null errors out of production entirely.