Null Safety Explained
KOTLIN › Language Idioms
Kotlin's headline feature is that a whole category of crashes, the NullPointerException, is caught at **compile time** instead of blowing up at runtime. The trick: nullability is baked into the *type*.
A plain String can never hold null:
var name: String = "Ada"
name = null // compile error: Null can not be a value of a non-null type String
Add a ? and you get a different type, String?, that *may* be null:
var name: String? = "Ada"
name = null // fine
The payoff: the compiler won't let you dereference a nullable value (name.length) until you've proven it isn't null. Every operator in this lesson is just a different way to give that proof.
The safe-call operator ?. is the everyday tool. It calls through **only** when the receiver is non-null; if the receiver is null, the whole expression short-circuits to null instead of throwing:
val name: String? = null
val len = name?.length // null, no crash
The subtlety interviewers look for: **the result type becomes nullable**. name?.length isn't an Int, it's an Int?, because it might be null. That nullability flows down a whole chain:
val city = user?.address?.city // String?, null if any link is null
One null anywhere in the chain and the result is simply null, with no NPE at any step.
When you don't want to be left holding a nullable, the elvis operator ?: supplies a fallback:
val len = name?.length ?: 0 // Int, never null
Read a ?: b as "a, or if that's null, b". The right side is evaluated **only** when the left is null (it short-circuits), which makes it the perfect spot for an early exit:
fun greet(user: User?) {
val u = user ?: return // bail out on null
println("Hi ${u.name}") // u is non-null from here on
}
Because return and throw are expressions in Kotlin, they drop straight into the elvis right-hand side. But why does token below type as non-null String rather than String?? Because return has type Nothing, a special type with no instances that is a subtype of every other type. Kotlin computes the elvis expression's type from both branches, and since Nothing is a subtype of everything, it can't broaden that type, so the whole expression keeps the left side's non-null type.
Sometimes you *know* a value is non-null but the compiler doesn't. The non-null assertion !! overrides it:
val len = name!!.length // trust me, it's not null
!! converts T? to T, and if you're wrong, it throws a NullPointerException right there. That's the whole catch: **!! is the one operator that reintroduces the very crash Kotlin was designed to prevent.**
Treat it as a code smell. Almost every !! has a cleaner replacement, ?., ?:, or a ?.let. The rare legitimate use is when an invariant guarantees non-null but the type can't express it (say, a field set once in onCreate). If you reach for !! in an interview, be ready to justify why the value can't be null.
You don't always need an operator. After you check for null, the compiler **smart-casts** the value to its non-null type for the rest of that scope:
fun show(name: String?) {
if (name != null) {
println(name.length) // name is String in here, no ?. needed
}
}
The compiler remembers the check and lets you use name as a plain String inside the branch. The same works with an elvis-return or an && chain.
The limitation interviewers probe: a smart cast has to *guarantee* the value hasn't changed between the check and the use. So it's refused on a mutable var property, since another thread, or a custom getter, could hand back null the second time:
var cached: String? = null
fun render() {
if (cached != null) {
println(cached.length) // error: smart cast to String is impossible
}
}
The usual fix is to copy into a local val first, which can't change out from under you.
Kotlin has a third way to work with a value that's non-null but not ready yet: lateinit. Mark a var property lateinit, and you promise to assign it before first use, without wrapping it in a nullable type:
class UserSession {
lateinit var token: String
fun start() { token = fetchToken() }
}
Unlike a nullable var that starts life holding null, lateinit gives you a real non-null String in the type system, so no ?. or !! is ever needed to use it. The cost shows up if you read it too early: Kotlin throws UninitializedPropertyAccessException, not a NullPointerException, because there's no null sentinel to check, the backing storage is simply unset. If you're not sure it's been assigned yet, guard with ::token.isInitialized before reading it.
The let scope function pairs beautifully with ?.. Writing value?.let { ... } runs the block **only** when value is non-null, and inside, the value arrives as it, already smart-cast to non-null:
val user: User? = findUser(id)
user?.let {
save(it) // it: User, not User?
}
Why not just if (user != null)? Two reasons. First, it works on values you *can't* smart-cast, like a mutable property or a function result, because ?.let captures the value once:
cached?.let { println(it.length) } // fine, even though cached is a var
Second, because let returns its block's result, it transforms in the same breath:
val domain: String? = email?.let { it.substringAfter("@") }
Null in, null out; otherwise the computed value.
Two stdlib functions fold a null check and an unwrap into a single call. requireNotNull(value) returns value with its non-null type when it isn't null, and throws IllegalArgumentException when it is:
fun process(config: String?) {
val c: String = requireNotNull(config) { "config must be set" }
println(c.length)
}
checkNotNull does exactly the same job but throws IllegalStateException instead, the usual convention being require for bad *arguments* and check for bad internal *state*. Both beat a manual ?: throw IllegalArgumentException(...): the message lambda is optional, and the return type is narrowed to non-null right at the call site, so everything after that line can treat the value as a plain String.
Two more idioms round out the toolkit.
**Safe cast as?** returns null instead of throwing when a cast doesn't fit, unlike plain as, which throws ClassCastException:
val s = obj as? String // String?, null if obj isn't a String
val name = (obj as? User)?.name ?: "guest"
Paired with elvis, it's the clean "cast or fall back" one-liner.
**Collections** have their own null helpers. When you have a List<String?> and want only the real values, filterNotNull drops the nulls *and* narrows the element type:
val clean: List<String> = names.filterNotNull()
And mapNotNull does the same while transforming, ideal for "parse each, keep the ones that worked":
val ids: List<Int> = raw.mapNotNull { it.toIntOrNull() }
Some stdlib functions are declared as extensions on the *nullable* type itself, not the non-null type, so you can call them directly on a String? with no safe call:
val s: String? = null
if (s.isNullOrEmpty()) {
println("nothing to show")
}
isNullOrEmpty, isNullOrBlank, and orEmpty are all written as fun String?.isNullOrEmpty(): Boolean, meaning this inside the function body can itself be null, and the function checks for that before doing anything else. That's different from .length, which is only declared on String, so it demands ?. or a prior check. When you see a nullable-looking call with no ?. in front of it, check whether it's one of these nullable-receiver extensions before assuming something's wrong.
One more spot where Kotlin quietly avoids an NPE: the == operator. Comparing two nullable values never throws, even when the left side is null:
val a: String? = null
val b: String? = null
println(a == b) // true
println(a == "x") // false
That's because == doesn't call .equals() directly on a, it compiles to a null-safe check, roughly a?.equals(b) ?: (b === null). If a is null, it skips the call and just asks whether b is also null. Contrast that with calling .equals() yourself: a.equals(b) on a null a throws immediately, because that's a genuine member call on a null receiver, with no safe call protecting it. == is safe by construction; a raw .equals() call is not.
The one place null safety leaks is **Java interop**. When you call into unannotated Java, Kotlin can't tell whether the result is nullable, so it produces a *platform type*, written String!. The compiler relaxes its rules and lets you treat the value as non-null with no ?.:
// Java: String getName() { return null; }
val name = obj.getName() // platform type String!
val len = name.length // compiles, but NPE at runtime if it was null
That's the trap: no !! in sight, yet it crashes. The defense is to **annotate the boundary**, declare the type you actually expect and let the compiler enforce it:
val name: String? = obj.getName() // now you must handle null
That's the whole model. Nullability lives in the type; ?. and ?: handle it safely; !! opts out (rarely); smart casts and ?.let scope you to the non-null path; and platform types are the seam to watch. Picking the right operator and saying *why* out loud is exactly what the interviewer is listening for.