Nullable Types and the Safe Call Operator
In Kotlin, every type comes in two flavors: an ordinary type that can never hold null, and a nullable version of that type, written with a trailing ?, that explicitly allows null. This isn’t a runtime convention — it’s baked into the type system, so the compiler refuses to compile code that might dereference a null value without first handling that possibility. The safe call operator (?.) is the everyday tool for working with nullable values: it calls a method or reads a property only if the receiver isn’t null, otherwise it short-circuits to null instead of crashing. Together, nullable types and safe calls are Kotlin’s answer to what Tony Hoare called his “billion-dollar mistake” — the null reference — and they are one of the biggest reasons developers coming from Java find Kotlin such a relief.
Overview: How Nullable Types Work
By default, every type in Kotlin is non-nullable. A variable declared as String is guaranteed by the compiler to never hold null — you simply cannot assign null to it, and the compiler rejects the program if you try. If you need a variable that might legitimately have no value, you opt in explicitly by adding ? to the type: String?. This creates a distinct type from String — a String? cannot be passed anywhere a plain String is expected without first proving it isn’t null.
Under the hood, both String and String? compile to the same JVM bytecode representation (there’s no separate runtime type for nullability on the JVM). The difference lives entirely at compile time: the Kotlin compiler tracks which expressions might be null and statically rejects any attempt to call a member on a nullable expression without a safe operator. This is why null-safety in Kotlin is often described as “compile-time enforced” — the checks happen before your program ever runs, not with a defensive if (x != null) scattered everywhere at runtime.
When you do check a nullable value for null — for example with if (value != null) — the compiler performs what’s called a smart cast: inside the block where you’ve proven the value isn’t null, the compiler treats it as the non-null type automatically, and you can use it exactly like a non-nullable value with no further conversion needed. Smart casts only work for values the compiler can prove won’t change between the check and the use, which is why they apply to local vals and read-only properties, but not to mutable var properties (more on that in Common Mistakes below).
Syntax
The core null-safety syntax has three pieces: declaring a nullable type, safely accessing it, and providing a fallback.
val nullableName: String? = "Kotlin" // can hold a String or null
val nonNullName: String = "Kotlin" // can never hold null
val length1 = nullableName?.length // safe call: null if nullableName is null
val length2 = nullableName?.length ?: 0 // elvis: fall back to 0 if null
val length3 = nullableName!!.length // not-null assertion: throws if null
| Operator | Name | What it does |
|---|---|---|
? |
Nullable type marker | Appended to a type to allow null, e.g. Int? |
?. |
Safe call | Calls a member if the receiver isn’t null; otherwise evaluates to null |
?: |
Elvis operator | Supplies a default value when the left-hand expression is null |
!! |
Not-null assertion | Forces the type to non-null; throws NullPointerException if the value is actually null |
?.let { } |
Safe call + let | Runs a lambda only when the receiver isn’t null, with the value passed in non-null |
Examples
Example 1: Basic safe calls
fun main() {
val name: String? = "Kotlin"
val nullName: String? = null
println(name?.length)
println(nullName?.length)
}
Output:
6
null
The first safe call succeeds because name holds a real string, so ?.length returns its length, 6. The second safe call short-circuits: because nullName is null, Kotlin never attempts to read .length at all, and the whole expression evaluates to null, which println happily prints as the text null.
Example 2: The Elvis operator for fallback values
fun main() {
val name: String? = null
val length = name?.length ?: 0
println("Length: $length")
val greeting: String? = null
val message = greeting ?: "Hello, stranger!"
println(message)
}
Output:
Length: 0
Hello, stranger!
The Elvis operator ?: reads as “or else”: if the expression on its left is null, use the expression on its right instead. Combined with a safe call, name?.length ?: 0 means “the length of name, or 0 if there is no name” — and the whole expression has type Int, not Int?, because the Elvis operator guarantees a non-null result whenever its fallback is non-null.
Example 3: A realistic lookup with chained safe calls
data class User(val name: String, val email: String?)
fun findUser(id: Int): User? {
val users = mapOf(
1 to User("Alice", "alice@example.com"),
2 to User("Bob", null)
)
return users[id]
}
fun main() {
val ids = listOf(1, 2, 3)
for (id in ids) {
val user = findUser(id)
val domain = user?.email?.substringAfter("@")
println("User $id domain: ${domain ?: \"no email on file\"}")
}
findUser(1)?.let { u ->
println("Found user: ${u.name}")
}
}
Output:
User 1 domain: example.com
User 2 domain: no email on file
User 3 domain: no email on file
Found user: Alice
This mirrors a very common real-world shape: a lookup that might not find anything (findUser returns User?), feeding into a field that itself might be absent (email: String?). The chain user?.email?.substringAfter("@") only reaches substringAfter if both user and email are non-null; for id 2 the user exists but has no email, and for id 3 there’s no user at all — both cases flow through to the same null, and the Elvis operator turns that into a friendly message. Notice too that User is a data class: Kotlin generates equals(), hashCode(), toString(), and copy() for it automatically, which is why we can construct and compare User values with almost no boilerplate.
How It Works Step by Step
Walking through Example 3 for id = 2: findUser(2) looks up the map and returns the User("Bob", null) wrapped as a non-null result, since the key exists. The safe call user?.email sees that user is not null, so it proceeds to read .email, which is null for Bob. The next safe call in the chain, ?.substringAfter("@"), now sees a null receiver, so it skips the call entirely and the whole chained expression evaluates to null without ever risking a NullPointerException. That null flows into the Elvis operator inside the string template, which substitutes the fallback text. For id = 3, the very first step differs: users[3] finds no matching key, so findUser itself returns null, and the first safe call in the chain short-circuits immediately — but the result is the same fallback message, because a safe call chain stops at whichever link first hits null and propagates null all the way to the end.
Common Mistakes
Mistake 1: Reaching for !! instead of handling null
The not-null assertion operator !! tells the compiler “trust me, this is never null” — and if you’re wrong, it throws a NullPointerException at runtime, which is exactly the crash Kotlin’s type system exists to prevent. New Kotlin developers often reach for !! just to make the compiler stop complaining, which quietly reintroduces Java’s null-pointer risk.
fun main() {
val input: String? = null
val length = input!!.length
println(length)
}
This compiles fine, but crashes as soon as it runs, because input really is null when !! forces it to be treated as non-null. The fix is to handle the null case explicitly with a safe call and a fallback:
fun main() {
val input: String? = null
val length = input?.length ?: 0
println(length)
}
Now there’s no crash: when input is null, length simply becomes 0. As a rule of thumb, reserve !! for situations where a null at that point genuinely indicates a bug in your own program (and you want a loud crash to reveal it), not for values that can legitimately be absent.
Mistake 2: Expecting a smart cast on a mutable property
Smart casts only work when the compiler can guarantee the value can’t change between the null check and the use. A var property on a class doesn’t meet that bar — another thread, or a custom getter, could change it in between — so the compiler refuses to smart-cast it, even right after a null check.
class Container {
var value: String? = "hello"
}
fun printLength(c: Container) {
if (c.value != null) {
println(c.value.length) // Error: smart cast to 'String' is impossible, because 'c.value' is a mutable property that could change
}
}
The fix is to copy the property into a local val first; a local val can’t be reassigned out from under you, so the compiler can safely smart-cast it after the check:
class Container {
var value: String? = "hello"
}
fun printLength(c: Container) {
val local = c.value
if (local != null) {
println(local.length)
} else {
println("no value")
}
}
fun main() {
val container = Container()
printLength(container)
}
Output:
5
Copying c.value into local takes a stable snapshot, so once the null check passes, local is smart-cast to String for the rest of the block, and local.length compiles and prints 5.
Best Practices
- Prefer
?.and?:over!!in normal code paths — save!!for cases where anullthere truly signals a bug you want to fail loudly on. - Model genuine absence with a nullable type (
String?) rather than sentinel values like empty strings or-1. - Use
?.let { }when you need to run several statements only in the non-null case, instead of nesting anif (x != null)block. - Assign a nullable expression (especially a
varproperty or function call result) to a localvalbefore checking it, so the compiler can smart-cast it afterward. - Avoid long chains like
a?.b?.c?.din production code; extract a well-named helper function so a reader doesn’t have to trace every possiblenullpoint. - Don’t overload the meaning of
nullto mean both “absent” and “error” — use exceptions or a result type for genuine failures, and reservenullfor values that legitimately don’t exist.
Practice Exercises
- Write a function
fun firstVowel(s: String?): Char?that returns the first vowel found ins, ornullifsisnullor contains no vowels. Do not use!!anywhere. - Given
val scores = mapOf("Ann" to 92, "Bo" to null, "Cy" to 78)where anullscore means the student was absent, print a line per student reading either"Ann: 92"or"Bo: absent"using the Elvis operator. - Take a snippet that uses
!!three separate times to read a nested nullable property chain, and rewrite it using only?.,?:, and?.letso it no longer contains a single!!.
Summary
- Every Kotlin type has a non-nullable form by default and an explicit nullable form written with a trailing
?, such asStringvsString?. - The safe call operator
?.calls a member only when the receiver isn’tnull, otherwise the whole expression becomesnull— and this short-circuits through chained calls. - The Elvis operator
?:supplies a fallback value for thenullcase, turning a nullable result into a guaranteed non-null one. - The not-null assertion
!!throwsNullPointerExceptionat runtime if the value is actuallynull; use it sparingly and only when anullthere means your program has a bug. - Smart casts let the compiler treat a checked nullable local
valas non-null automatically, but this doesn’t extend to mutablevarproperties — copy them to a localvalfirst. ?.let { }is the idiomatic way to run a block of code only when a nullable value is present.
