Nullable Types and Null Safety in Kotlin
Kotlin 13 min read
The type system splits String from String?, the safe call and Elvis operators that make working with the second bearable, and the platform types that let Java hand you a null the compiler never saw.
Kotlin’s answer to the null pointer exception is not a runtime check. It is a type distinction:
String cannot hold null and String? can, and the compiler refuses to let you use the second where
the first is required. The holes in that guarantee are all at the boundary with Java, and they are
worth knowing precisely.
Written against Kotlin 1.9.
Two types, not one
var name: String = "Kotlin"
name = null // compile error
var maybe: String? = "Kotlin"
maybe = null // fine
val length = maybe.length // compile error — maybe could be null
String? is a distinct type, and every operation that could dereference it is rejected until the
nullability has been dealt with. That check happens at compile time and costs nothing at runtime.
Prefer the non-nullable type by default. Nullability that is not in the domain (a name that always exists, an id that is always assigned) makes every call site carry a check for a case that cannot happen.
The four ways to unwrap
A null check, which the compiler tracks:
if (maybe != null) {
println(maybe.length) // smart cast to String
}
Inside the branch the type is String. That is a smart cast,
and it works for a local val but not for a var that another thread could change, nor for a
mutable property of another class, the compiler says so rather than guessing.
The safe call, ?.:
val length: Int? = maybe?.length
Returns the value, or null if the receiver is null. The result type gains a ?, which is what makes
chains work:
val city: String? = user?.address?.city
The whole chain evaluates to null if any link is null, and nothing after the first null is evaluated.
The Elvis operator, ?:, for a default:
val length = maybe?.length ?: 0
val name = user?.name ?: throw IllegalArgumentException("user required")
The right-hand side is evaluated only when the left is null. Because throw and return are
expressions in Kotlin, the Elvis operator doubles as an early exit:
fun process(input: String?) {
val value = input ?: return
// value is String from here
}
The not-null assertion, !!:
val length = maybe!!.length // NullPointerException if null
This turns a compile-time guarantee back into a runtime crash. It has narrow legitimate uses (a value the compiler cannot see is initialised, a test where a null is a test failure) and it is mostly a sign that a type should have been non-nullable further up.
A !! on a chain is worse: a!!.b!!.c!! gives a stack trace naming a line, not which of the three
was null. Break it up, or restructure.
Collections
val a: List<String?> = listOf("x", null) // list of nullable strings
val b: List<String>? = null // nullable list of strings
val c: List<String?>? = null // both
The ? binds to whatever it follows, so the three are genuinely different. filterNotNull converts
the first to a non-nullable list:
val names: List<String> = listOfNullables.filterNotNull()
orEmpty() handles the second, which is usually better than treating an absent list as a special
case:
for (item in maybeList.orEmpty()) { }
let, and what it is actually for
maybe?.let { value ->
println(value.length) // value is String
}
?.let runs the block only when the receiver is non-null. It earns its place when the value is an
expression that should not be evaluated twice, or a property the compiler cannot smart-cast:
user.middleName?.let { println("Middle name: $it") }
For a simple if (x != null) on a local, the plain check reads better. let chained several deep is
harder to follow than the nested if it replaced.
?.let { } ?: run { } as an if/else is a known trap, if the let block returns null, the Elvis
branch also runs. Use a real if when both branches matter.
The boundary with Java
Java has no nullability in its type system, so Kotlin cannot know whether a Java method can return
null. Such a type is a platform type, written String! in errors and never in source:
val s = javaObject.getName() // inferred type String! — no check applied
println(s.length) // compiles; throws at runtime if it was null
This is the hole in the guarantee. Kotlin permits the call, and a null produces a
NullPointerException at the point of use: exactly the Java behaviour the type system exists to
prevent.
Two defences. Annotate the Java side: Kotlin honours @Nullable and @NonNull from JSR-305,
JetBrains, Android and Jakarta, and a platform type becomes a real one:
public @Nullable String getName() { ... }
Or declare the type explicitly on the Kotlin side, which turns the assumption into a checked one:
val s: String? = javaObject.getName() // now the compiler enforces the check
val t: String = javaObject.getName() // throws immediately if null, at the assignment
The second form is worth noting: assigning a platform type to a non-nullable type inserts a check, so the failure happens at the boundary with a clear message rather than three frames later.
Kotlin also inserts null checks on the parameters of public functions, so a Java caller passing null
to a Kotlin function taking String fails immediately at the call rather than corrupting state.
lateinit and by lazy
class Service {
lateinit var repository: Repository // assigned later, non-nullable
fun use() {
if (::repository.isInitialized) { }
}
}
lateinit says “this is non-nullable and will be set before use”. Reading it early throws
UninitializedPropertyAccessException, which is a clearer failure than a null. It works only on a
var, only on a non-primitive type, and only where the property is not val.
by lazy is the better answer when the value can be computed on first access:
val config: Config by lazy { loadConfig() }
Non-nullable, initialised exactly once, thread-safe by default.
Both exist to avoid making a type nullable purely because of initialisation order. That is the general principle: nullability should describe the domain, not the lifecycle.
Null against absent
One distinction the type system does not make for you: null can mean “no value exists” or “the
value is unknown”, and a single ? covers both. In an API those are different answers, a field
omitted from a JSON body is not the same as a field explicitly set to null, and a partial update
depends on telling them apart.
Kotlin’s usual answer is a sealed hierarchy rather than a second kind of null:
sealed interface Patch<out T> {
data object Unchanged : Patch<Nothing>
data class Set<T>(val value: T?) : Patch<T>
}
Unchanged and Set(null) are now different values with different types, and a when over them is
exhaustive. That is more machinery than a nullable field and it is the honest encoding whenever both
cases are real.
The lighter-weight version is a nullable wrapper: String?? is not a thing, but
Patch<String> or even Result<String?> gives the second level. Reaching for one is a signal to
check whether the domain really has two absences or whether one of them is an artefact of the
transport.
More Kotlin in the Kotlin guides, including type checks and smart casts.
Frequently asked questions
What is the difference between String and String?
They are different types. String cannot hold
null and the compiler rejects any assignment of one; String? can, and every dereference must be
guarded.
What does the safe call operator do?
?. evaluates to null when the receiver is null instead of
throwing, and the result type becomes nullable. A chain stops at the first null.
When should I use !!?
Rarely. It converts a compile-time guarantee into a runtime crash. It is defensible in tests and where the compiler cannot see an initialisation; anywhere else it usually means a type should be non-nullable earlier.
What is the Elvis operator?
?: supplies a value when the left side is null. Because throw and
return are expressions, it also works as an early exit, val x = maybe ?: return.
Can Kotlin still throw a NullPointerException?
Yes: from !!, from a platform type returned by
Java, from lateinit misuse, and from explicitly throwing one. The type system covers pure Kotlin
code.
What is a platform type?
A type from Java whose nullability is unknown, shown as String!.
Kotlin applies no check, so it behaves like Java. Annotate the Java side or declare the type
explicitly on assignment.
Why does my smart cast not work?
The value is a var, a property of another class, or otherwise
something the compiler cannot prove is unchanged between the check and the use. Copy it into a local
val first.
let or an if check?
A plain if (x != null) for a local. ?.let when the receiver is an
expression that should not be re-evaluated, or a property that cannot be smart-cast.
Is ?.let … ?: run a safe if/else?
No. If the let block itself evaluates to null, the Elvis
branch runs too. Use a real if when both branches have effects.
lateinit or a nullable type?
lateinit when the value is genuinely non-nullable and only the
initialisation order is the problem, the failure is then a clear “not initialised” rather than a
null. by lazy is better still when the value can be computed on demand.