Kotlin Null Safety: Complete Guide to Nullable Types and Safe Calls
How Kotlin moves null checking from runtime to compile time — nullable types, safe calls, the Elvis operator, smart casts, and the specific places where a NullPointerException can still occur.
Kotlin's headline safety feature is that nullability is part of the type system. String and String? are different types, and the compiler refuses any operation on the nullable one that would fail if the value were absent.
The practical effect is that the most common runtime error in JVM languages becomes a compile error. The important nuance — covered at the end — is that this guarantee has holes where Kotlin meets Java, and knowing where they are is part of using the feature properly.
The Question Mark Is Part of the Type
A type without ? cannot hold null, and the compiler enforces that at every assignment. A type with ? can, and the compiler then refuses direct member access until you prove the value is present.
// Non-nullable: cannot ever hold null
var name: String = "Ada"
// Error: Null can not be a value of a non-null type String
// name = null
// Nullable: may hold null
var nickname: String? = "Addy"
nickname = null // fine
// Direct access on a nullable type is rejected
// Error: Only safe (?.) or non-null asserted (!!.) calls are allowed
// println(nickname.length)
// A non-nullable type is not assignable from a nullable one
val maybe: String? = "hello"
// Error: Type mismatch
// val definite: String = maybe
// The reverse is always safe
val definite: String = "hello"
val widened: String? = definite // fine
This distinction propagates through generics too: List<String>, List<String?> and List<String>? are three different types — a list of strings, a list that may contain nulls, and a list that may itself be null.
Working with Nullable Values Without Branching
The safe call ?. evaluates to null instead of throwing when the receiver is null. The Elvis operator ?: supplies a fallback for a null result. Together they replace most explicit null checks.
data class Address(val city: String?, val postcode: String?)
data class Customer(val name: String, val address: Address?)
data class Order(val id: Int, val customer: Customer?)
val order: Order? = loadOrder()
// Safe call: null if the receiver is null
val length: Int? = order?.customer?.name?.length
// The whole chain short-circuits on the first null —
// no nested if statements required
val city: String? = order?.customer?.address?.city
// Elvis: provide a default
val cityOrDefault: String = order?.customer?.address?.city ?: "Unknown"
// Elvis with an early return — a very common guard pattern
fun shipLabel(order: Order?): String {
val customer = order?.customer ?: return "No customer"
val city = customer.address?.city ?: return "No address"
return "${customer.name}, $city"
}
// Elvis with throw for genuinely invalid state
fun requireCity(order: Order): String =
order.customer?.address?.city
?: throw IllegalStateException("Order ${order.id} has no city")
// Safe call on a collection operation
val names: List<String> = order?.customer?.let { listOf(it.name) } ?: emptyList()
?. on an assignment is also valid: person?.name = "x" performs the assignment only if person is non-null, rather than throwing.When the Compiler Tracks Your Checks
After a null check, the compiler treats the value as non-nullable within that scope. No cast or unwrap is needed — this is the same control-flow analysis that powers type narrowing in other statically typed languages.
fun describe(value: String?) {
if (value != null) {
// Smart cast to String — direct access is allowed here
println(value.length)
println(value.uppercase())
}
}
// Works with early return too
fun describeOrBail(value: String?) {
if (value == null) return
println(value.length) // smart cast for the rest of the function
}
// Smart casts apply to type checks as well
fun area(shape: Any): Double = when (shape) {
is Circle -> Math.PI * shape.radius * shape.radius // smart cast
is Rect -> shape.width * shape.height
else -> 0.0
}
// A var property CANNOT be smart cast: another thread, or a custom
// getter, could change it between the check and the use.
class Holder {
var value: String? = null
fun broken() {
if (value != null) {
// Error: Smart cast to 'String' is impossible,
// because 'value' is a mutable property
// println(value.length)
// Fix: copy into a local val
val local = value
if (local != null) println(local.length)
}
}
}
val locals and immutable properties, but not for var properties, open properties, or properties with custom getters.let, run, also and takeIf
Combined with a safe call, the scope functions run a block only when a value is present, which often reads better than an if and keeps the non-null value scoped to where it is used.
val email: String? = user.email
// let — run a block only if non-null; `it` is the non-null value
email?.let { addr ->
sendWelcome(addr) // addr: String, not String?
}
// Transform and fall back in one expression
val domain: String = email?.let { it.substringAfter('@') } ?: "unknown"
// also — side effect, returns the original value (good for logging)
val saved = user.also { log.info("saving ${it.id}") }.let(repo::save)
// run — like let but with `this` as the receiver
val summary = user.run { "$name <$email>" }
// takeIf — null unless the predicate holds; pairs well with Elvis
val validEmail = email?.takeIf { it.contains('@') } ?: return
// apply — configure and return the receiver
val request = Request().apply {
url = "https://api.example.com"
timeout = 30
}
| Function | Receiver as | Returns | Typical use |
|---|---|---|---|
let | it | Block result | Transform a nullable value |
run | this | Block result | Compute from several members |
also | it | The receiver | Logging, side effects in a chain |
apply | this | The receiver | Configuring a new object |
takeIf | it | Receiver or null | Turn a condition into a nullable |
?.let blocks several levels deep — nested scope functions become harder to read than the equivalent Elvis-with-early-return guards. Prefer the guard style for multiple preconditions.The Four Remaining Holes
Kotlin's guarantee is strong but not absolute. There are exactly four realistic ways to see an NPE, and all of them involve stepping outside the type system deliberately or crossing into Java.
1. The Not-Null Assertion
val name: String? = null
// Throws NullPointerException — this is !! doing exactly what it says
// val length = name!!.length
// Almost always there is a better option:
val safe1 = name?.length ?: 0
val safe2 = name ?: error("name was required but missing")
// If you must assert, assert once and explain why
val config = loadConfig()
?: error("config must be initialised before first use")
Treat every !! as a comment reading "I am certain this is non-null". Where that certainty comes from program structure rather than a check, prefer ?: with error() — it produces a message explaining what went wrong instead of a bare NPE.
2. lateinit Accessed Too Early
class Service {
// Promises: non-null, but assigned after construction
private lateinit var client: HttpClient
fun init() {
client = HttpClient()
}
fun call() {
// UninitializedPropertyAccessException if init() has not run
client.get("/health")
}
fun callSafely() {
if (::client.isInitialized) {
client.get("/health")
}
}
}
// Often a lazy delegate is better: initialised on first access,
// thread-safe by default, and genuinely non-null.
class BetterService {
private val client: HttpClient by lazy { HttpClient() }
}
lateinit cannot be used with primitive types or val. Where the value can be computed on demand, by lazy is both safer and simpler.3. Platform Types from Java
Java has no nullability in its type system, so Kotlin treats values returned from Java as platform types, written String!. These bypass the null checks entirely — the compiler allows direct access and an NPE surfaces at runtime.
// Java: public String getName() { return null; }
// Kotlin sees String! — a platform type, checks relaxed
val name = javaObject.name // inferred as String!
// Compiles, then throws at runtime if Java returned null
// println(name.length)
// Fix: declare the nullability you actually expect
val safe: String? = javaObject.name
println(safe?.length ?: 0)
// On the Java side, annotate so Kotlin can see the intent:
// @Nullable String getName() -> String?
// @NotNull String getName() -> String
This is why annotating Java APIs with @Nullable and @NotNull matters: it is the only way the Kotlin compiler can enforce anything across the boundary.
4. Leaking this During Construction
If a constructor passes this to code that reads a property not yet initialised, that property is observed as null even though its type says otherwise. It is rare, but it is a genuine hole.
• Chain with ?. and supply fallbacks with ?: instead of nested if checks.
• Copy a var into a local val to enable a smart cast.
• Use ?.let for present-value blocks; guard clauses for several preconditions.
• Prefer ?: error("why") over !! so failures explain themselves.
• Favour by lazy over lateinit when the value can be computed on demand.
• Annotate Java APIs with @Nullable/@NotNull — platform types skip all checks.
Summary
Kotlin eliminates the ordinary NullPointerException by making nullability a property of types rather than a runtime condition. Safe calls, the Elvis operator and smart casts then make working with genuinely optional values concise rather than defensive.
The guarantee holds as long as you stay inside it. The holes are few and well-defined: !!, uninitialised lateinit, platform types from unannotated Java, and leaking this during construction.
In practice, writing nullability honestly in signatures and reserving !! for cases that genuinely cannot be expressed otherwise is enough to keep null errors out of production entirely.