Null Safety Explained
In Java, any object reference can be null, and forgetting to check for it produces a NullPointerException at the worst possible moment, usually in production. Kotlin bakes the possibility of null directly into its type system, so the compiler forces you to handle nullability where you write the code instead of where a user finds it. This single design choice removes an entire category of runtime crashes and is one of the biggest reasons developers describe Kotlin as "Java, but safer." This lesson covers every tool Kotlin gives you for working with nullable values: the nullable type itself, the safe call and Elvis operators, smart casts, the not-null assertion, and the mistakes that trip up newcomers.
Overview: How Null Safety Works
Every type in Kotlin comes in two flavors: a non-null version, like String, and a nullable version, written with a trailing question mark, like String?. These are genuinely different types as far as the compiler is concerned. A String variable can never hold null; assigning null to it is a compile-time error, not a runtime surprise. A String? variable can hold either a real String or null, and the compiler will not let you call a member on it, such as .length, without first proving that it isn’t null.
This proof can come from a few places: an explicit if (x != null) check, the safe-call operator ?., the Elvis operator ?:, or the not-null assertion !! (which trades the compile-time guarantee for a runtime crash). Whenever the compiler can statically verify that a nullable value cannot be null at a particular point in the code, it performs a smart cast: inside that scope, the value is treated as the non-null type without you casting it yourself. Smart casts are one of Kotlin’s quieter but most useful features — once you write if (x != null), every line after it in that branch can use x as if it were declared non-null.
Smart casts only work when the compiler can guarantee the value hasn’t changed between the check and the use — a local val, or a local var that a lambda can’t capture concurrently, satisfies this. A var property of a class does not, because another thread or a custom getter could change it in between, so the compiler refuses the smart cast (see Common Mistakes). There is one more wrinkle worth knowing about: when Kotlin calls into Java code, the compiler cannot see whether a Java method can return null, so it treats the result as a platform type (shown as String! in tooling), which you can treat as either nullable or non-null at your own risk. That topic is covered fully in the Kotlin/Java interop lesson, but it’s worth remembering that null-safety guarantees stop at the interop boundary.
Syntax
Kotlin’s null-safety toolkit is small but covers every situation you’ll run into. The table below is the reference; the Examples section shows each piece in a realistic program.
| Syntax | Name | Meaning |
|---|---|---|
T? |
Nullable type | Marks a type as allowed to hold null, e.g. String? |
?. |
Safe call | Calls a member only if the receiver isn’t null; otherwise the whole expression is null |
?: |
Elvis operator | Supplies a default value (or a return/throw) when the left side is null |
!! |
Not-null assertion | Converts T? to T, throwing NullPointerException if the value is null |
?.let { } |
Safe call + scope function | Runs the block only when the receiver is non-null, passed in as it |
as? |
Safe cast | Casts to a type, producing null instead of throwing on failure |
lateinit var |
Deferred initialization | A non-null var assigned later; throws UninitializedPropertyAccessException if read too early |
Examples
Example 1: Declaring a nullable variable and using a safe call
fun main() {
var name: String? = "Kotlin"
println(name?.length)
name = null
println(name?.length)
}
Output:
6
null
name?.length is a safe call: if name is not null, it behaves exactly like name.length; if name is null, the whole expression evaluates to null instead of throwing. Because the result of a safe call is itself nullable (Int? here), println prints the literal text null when there’s nothing there — no exception is ever thrown.
Example 2: Falling back with the Elvis operator
fun describeLength(text: String?): Int {
return text?.length ?: -1
}
fun main() {
println(describeLength("Hello"))
println(describeLength(null))
}
Output:
5
-1
The Elvis operator ?: supplies a default when the left-hand side is null. text?.length ?: -1 reads as "the length of text, or -1 if text is null." The right side of ?: is only evaluated when needed, so you can also use it to return or throw from a function early, e.g. val x = maybeNull ?: return.
Example 3: Nullable properties in a data class
data class User(val name: String, val email: String?)
fun sendWelcomeEmail(user: User) {
val email = user.email
if (email != null) {
println("Sending welcome email to $email")
} else {
println("No email on file for ${user.name}")
}
}
fun main() {
val alice = User("Alice", "alice@example.com")
val bob = User("Bob", null)
sendWelcomeEmail(alice)
sendWelcomeEmail(bob)
alice.email?.let { println("Length of email: ${it.length}") }
bob.email?.let { println("Length of email: ${it.length}") }
}
Output:
Sending welcome email to alice@example.com
No email on file for Bob
Length of email: 17
This example models a realistic situation: a User whose email is optional. Because email is typed String?, every place that reads it must account for the possibility that it’s missing. sendWelcomeEmail uses an explicit if (email != null) check with smart casting; the calls at the bottom use ?.let { }, which runs the lambda only when the receiver isn’t null, passing it in as it. Both styles compile to the same idea — "do this only if there’s a value" — and which reads better depends on how much logic sits inside the block. Notice bob.email?.let { ... } prints nothing at all: the lambda simply never runs.
How It Works Step by Step
It helps to see exactly what the compiler and runtime do at each stage of a null-safety check:
- Type checking at compile time. The compiler tracks whether every expression’s type is nullable or non-null. Passing a
String?anywhere aStringis expected — a function parameter, avaldeclaration, a return type — is rejected before the code ever runs. - Safe-call evaluation. A chain like
a?.b?.cis evaluated left to right and short-circuits: ifais null, neitherbnorcis touched, and the whole expression isnull. No exception is thrown at any point. - Smart-cast narrowing. After a null check, the compiler narrows the static type inside the surviving branch —
xgoes fromString?toStringfor the rest of that scope. This is purely a compile-time bookkeeping trick; no cast instruction exists in the compiled bytecode. - Elvis evaluation.
a ?: bevaluatesafirst; only if it’s null does it evaluate and returnb. This makes it safe to put an expensive computation, athrow, or areturnon the right-hand side without paying for it on the non-null path. - Not-null assertion.
x!!compiles to a runtime null check followed by an unconditional throw ofNullPointerExceptionif the check fails — the one place Kotlin reintroduces Java’s original behavior, on purpose, as an explicit opt-in.
Common Mistakes
Mistake 1: Reaching for !! instead of handling null
The not-null assertion operator feels like a quick way to silence the compiler, but it just moves the crash from compile time to runtime — exactly the bug null safety exists to prevent.
fun printLength(text: String?) {
println(text!!.length)
}
fun main() {
printLength(null)
}
Output:
Prints nothing to standard output. The program crashes with an uncaught NullPointerException because text is null and !! asserts it is non-null.
Calling text!!.length throws the moment text is null — nothing is printed first. Prefer a safe call with a sensible default:
fun printLength(text: String?) {
val length = text?.length ?: 0
println(length)
}
fun main() {
printLength(null)
printLength("Kotlin")
}
Output:
0
6
Now a null input produces 0 instead of crashing the program.
Mistake 2: Expecting a smart cast on a mutable property
Smart casts require the compiler to prove a value can’t change between the check and the use. A var property of a class can change through another thread or a custom setter at any moment, so the compiler refuses to smart-cast it, even right after a null check:
class Box {
var value: String? = null
fun printLength() {
if (value != null) {
// Compiler error: Smart cast to 'String' is impossible, because
// 'value' is a mutable property that could have been changed
// by this time
println(value.length)
}
}
}
The fix is to copy the property into a local val first. A local val can’t be reassigned, so the compiler can safely narrow its type:
class Box {
var value: String? = null
fun printLength() {
val current = value
if (current != null) {
println(current.length)
}
}
}
fun main() {
val box = Box()
box.value = "Kotlin"
box.printLength()
}
Output:
6
Mistake 3: Treating lateinit as a way to avoid thinking about null
lateinit var lets you declare a non-null property without an initial value, which is convenient for things like dependency injection or values a framework sets up later. But reading it before it’s assigned throws UninitializedPropertyAccessException — a different exception from NullPointerException, but the same underlying mistake of using a value before it exists:
class Config {
lateinit var name: String
fun printName() {
println(name)
}
}
fun main() {
val config = Config()
config.printName()
}
Output:
Prints nothing. The program crashes with an uncaught kotlin.UninitializedPropertyAccessException because name is read before it is assigned.
Make sure every code path assigns the property before anything reads it:
class Config {
lateinit var name: String
fun printName() {
println(name)
}
}
fun main() {
val config = Config()
config.name = "Production"
config.printName()
}
Output:
Production
Best Practices
- Prefer
?.and?:over!!almost everywhere; treat!!as a last resort and a code smell when you see it in review. - Model "absence" with a nullable type instead of a sentinel value like
-1or an empty string — it forces every caller to handle the missing case explicitly. - Use
?.let { }to run code only when a value is present instead of nesting anifblock. - If you need a smart cast on a mutable class property, copy it into a local
valfirst, then check that. - Reserve
lateinitfor properties genuinely guaranteed to be set before use (dependency injection, test setup, Android view binding), not as a shortcut around a nullable type. - Use
requireNotNull()orcheckNotNull()instead of a bare!!when you do need to fail fast — they let you attach a clear error message. - At Java interop boundaries, add an explicit null check on platform types (
String!) rather than trusting them; the Kotlin compiler cannot verify them for you.
Practice Exercises
- Write
fun firstCharOrDefault(text: String?, default: Char): Charthat returns the first character oftext, ordefaultiftextis null or empty, using only safe calls and the Elvis operator (no!!). It should return'K'for("Kotlin", 'X')and'X'for both(null, 'X')and("", 'X'). - Given
data class Address(val city: String?)anddata class Person(val name: String, val address: Address?), write a function that prints a person’s city, or"Unknown city"if either the address or the city is missing, using a single chained safe call. - Take a function that calls
text!!.uppercase()and rewrite it so it never throws: return an empty string whentextis null, using?.letor?:. Test it with both a null and a non-null argument and check the output matches what you expect.
Summary
- Kotlin types are non-null by default; adding
?creates a distinct nullable type, and the compiler enforces the difference everywhere. ?.safely navigates a chain of nullable values, short-circuiting tonullinstead of throwing.?:(the Elvis operator) supplies a fallback, areturn, or athrowwhen the left side is null, and only evaluates its right side when needed.- Smart casts let you drop the
?after a null check, but only for values the compiler can prove won’t change — not mutable class properties. !!converts a nullable value to non-null at the cost of a potential runtimeNullPointerException; use it sparingly and deliberately.lateinitdefers initialization of a non-nullvarand throwsUninitializedPropertyAccessExceptionif read too early — it is not a substitute for nullable typing.- Design with nullable types and safe operators instead of sentinel values or defensive
!!checks; the compiler will do the checking for you.
