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.
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:
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 functions — let, 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:
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:
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:
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).
// 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
letandalso— reaching foralsowhen you actually need the block's transformed result (that'slet's job), or reaching forletpurely 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
NullPointerExceptionat 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
valwithout ever considering whether it should be typed nullable — the inferred type silently follows whatever you happened to write, hiding the real question of whethernullis 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.