Null Safety in Depth

Chaining safe calls, the let/also/run scope functions, and platform types when calling Java code.

Chaining safe calls

The syntax-and-variables page covered the basics of ?., ?:, and !!. In real code, nullability rarely stops at just one level — a user might have a nullable address, whose city might itself be nullable, and so on. Safe calls (?.) chain naturally: the moment any link in the chain is null, the whole expression short-circuits to null immediately, without a NullPointerException and without needing a nested if for every level.

Kotlin
class Address(val city: String?)
class User(val address: Address?)

fun main() {
    val user: User? = User(Address("Austin"))
    val noAddress: User? = User(null)
    val noUser: User? = null

    println(user?.address?.city)      // Austin
    println(noAddress?.address?.city) // null — address itself was null, chain short-circuits
    println(noUser?.address?.city)    // null — user itself was null, chain short-circuits immediately
}

Each ?. in the chain only evaluates the next step if everything so far was non-null — this is exactly analogous to PHP's nullsafe ?-> operator or C#'s ?., and solves the same problem those languages' operators exist to solve: avoiding a pyramid of nested null checks just to safely read one deeply-nested property.

Chaining a safe call with the Elvis operator at the end is the idiomatic way to provide a final fallback for an entire chain, not just its last link:

Kotlin
val cityOrDefault = user?.address?.city ?: "Unknown city"
println(cityOrDefault) // Austin

val fallbackForMissingUser = noUser?.address?.city ?: "Unknown city"
println(fallbackForMissingUser) // Unknown city — the ENTIRE chain resolved to null, so the Elvis default kicked in

Scope functions: let, also, and run

Kotlin's standard library defines several scope functionslet, also, run, apply, and with — that execute a block of code in the context of an object. They differ in two independent ways: whether the object is referred to as it or as this inside the block, and whether the whole expression returns the object itself or the block's result. let, also, and run are the three you'll reach for constantly; the table below disambiguates all five for reference.

Function Object reference inside block Returns
let it The block's result
also it The object itself
run this The block's result
apply this The object itself
with this (not an extension — takes the object as an argument) The block's result

let is the one most closely tied to null safety — combined with a safe call, it runs a block only if the receiver isn't null, which is a very common real pattern:

Kotlin
val nickname: String? = "Ace"

nickname?.let {
    println("Nickname has ${it.length} characters") // only runs if nickname isn't null
}

val noNickname: String? = null
noNickname?.let {
    println("This never prints") // skipped entirely — the receiver was null
}

also is for a side effect that doesn't change what the expression evaluates to — logging, validating, or printing partway through a chain of calls, while still passing the original object along unchanged:

Kotlin
val numbers = mutableListOf(1, 2, 3)
    .also { println("Initial list: $it") } // logs, then passes the SAME list onward
    .also { it.add(4) }                      // mutates it, still passes the same list onward

println(numbers) // [1, 2, 3, 4]

run is useful for grouping a sequence of calls on an object and computing a result from them, without repeating the object's name at every step:

Kotlin
class StringBuilder2(var content: String = "") {
    fun append(text: String): StringBuilder2 { content += text; return this }
}

val result = StringBuilder2().run {
    append("Hello, ")
    append("World!")
    content.uppercase() // the block's LAST expression becomes 'run's result
}

println(result) // HELLO, WORLD!

run used without a receiver at all (a top-level run { ... } block) is also a common way to scope a group of local variables so they don't leak into the surrounding function — useful when a value is only needed to compute one other value and shouldn't stay in scope afterward.

Platform types when calling Java code

Java has no concept of nullability in its type system — a String returned from a Java method might or might not actually be null, and nothing in Java's own type signature says which. When Kotlin code calls into Java, it can't know for certain whether a given Java-typed value is nullable or not, so it represents it as a platform type, notated internally as String! (though this exact notation never appears directly in your own source code — you'll only see it in compiler error messages and tooling).

Kotlin
// Imagine UserRepository is a JAVA class with: public String findEmail(int id) { ... }

fun greetUser(repository: UserRepository, id: Int) {
    val email = repository.findEmail(id) // 'email' has a PLATFORM type here — Kotlin doesn't know if it can be null

    // You can treat it as non-null...
    println(email.uppercase()) // compiles fine — but throws NPE at RUNTIME if findEmail actually returned null

    // ...or treat it as nullable, which is the safer default
    val safeEmail: String? = repository.findEmail(id)
    println(safeEmail?.uppercase() ?: "No email") // handles a real null return gracefully
}

A platform type is Kotlin's pragmatic compromise for interop: it doesn't force you to write defensive null checks around every single Java call (which would be exhausting, since Java has no annotations telling Kotlin which values are actually safe), but it also doesn't protect you the way Kotlin's own compile-time null-safety does for Kotlin-declared types — the compiler simply trusts you to know the called Java method's actual contract. Treating every platform type as nullable by default (declaring the receiving variable's type explicitly as String?, as in the second example) is the safer habit, since it costs nothing when the value genuinely is never null and prevents a runtime NullPointerException when it sometimes is.

Common mistakes

  • Writing a long chain of nested if (x != null) checks to reach a deeply-nested property, when a chain of ?. (optionally ending in ?:) expresses exactly the same logic far more concisely.
  • Confusing let and also — reaching for also when you actually need the block's transformed result (that's let's job), or reaching for let purely for a side effect and ignoring its return value.
  • Treating a Java platform type as unconditionally non-null out of convenience, then being surprised by a NullPointerException at runtime from code the Kotlin compiler happily accepted — Kotlin's null-safety guarantees simply don't extend across the Java interop boundary.
  • Assigning a value from a platform type directly to an inferred val without ever considering whether it should be typed nullable — the inferred type silently follows whatever you happened to write, hiding the real question of whether null is actually possible.

Interview questions

Q: What's the difference between let and also, given that both refer to the receiver as it? let returns the result of its block — useful for transforming a value or computing something new from it. also always returns the original object itself, regardless of what the block computes — useful for a side effect (logging, an extra mutation, a validation check) performed in the middle of a call chain, where you want that original object to keep flowing to the next step unchanged.

Q: What is a platform type in Kotlin, and why can't the compiler enforce null safety on it the way it does for ordinary Kotlin types? A platform type (internally notated Type!) is what a value coming from Java code is treated as, because Java's type system carries no nullability information at all — Kotlin genuinely cannot know from a Java method's signature alone whether its return value can be null. Rather than blocking all Java interop or forcing defensive checks everywhere, Kotlin lets you use a platform type as either nullable or non-nullable, trusting the caller to know the actual Java contract — which means a NullPointerException is still possible at that specific interop boundary, unlike with values that are Kotlin-declared and non-nullable throughout.