Null Safety Deep Dive
Nullable Types
Section titled “Nullable Types”Kotlin’s type system distinguishes between nullable and non-null types at compile time:
var name: String = "Alice" // non-null: cannot hold nullvar nickname: String? = null // nullable: can hold null or a StringAssigning null to a non-null type is a compile-time error:
name = null // compile error: Null can not be a value of a non-null type StringNon-null types are the default. The ? suffix makes a type nullable. The compiler enforces this distinction throughout — you cannot pass a nullable String? where String is expected without explicit handling.
Safe Calls and Elvis
Section titled “Safe Calls and Elvis”Safe Call Operator (?.)
Section titled “Safe Call Operator (?.)”The safe call operator returns the value if the receiver is non-null, or null if the receiver is null. The entire chain short-circuits:
val length: Int? = name?.length // null-safe accessval city: String? = user?.address?.city // chains short-circuitNo NullPointerException at runtime — the compiler inserts null checks.
Elvis Operator (?:)
Section titled “Elvis Operator (?:)”The elvis operator provides a default value when the left side is null:
val displayName: String = name ?: "Anonymous"val len: Int = name?.length ?: 0It is equivalent to:
val displayName = if (name != null) name else "Anonymous"let Scope
Section titled “let Scope”let executes a block only when the receiver is non-null. Inside the block, it is the non-null unwrapped value:
name?.let { println("Name is ${it.uppercase()}") // it: String (non-null) println("Length: ${it.length}") // safe to access length}With explicit name:
name?.let { n -> println("Name is $n")}Common pattern for early returns:
fun process(user: User?) { val email = user?.email ?: return sendEmail(email)}Not-Null Assertions
Section titled “Not-Null Assertions”!! Operator
Section titled “!! Operator”The !! operator asserts that a value is non-null. If it is null, a KotlinNullPointerException is thrown at runtime:
val name: String? = getName()val len: Int = name!!.length // throws NPE if name is nullWhen to Use
Section titled “When to Use”Use !! only when you are certain the value is non-null and no better alternative exists:
// Acceptable: precondition known to holdval result = list.first()!!.someMethod()
// Better: avoid !! with safe alternativesval result = list.firstOrNull()?.someMethod()When to Avoid
Section titled “When to Avoid”Prefer !! alternatives in most cases:
// Instead of: val len = name!!.length// Use:val len = name?.length ?: throw IllegalStateException("name is required")val len = requireNotNull(name) { "name is required" }.lengthval len = name!!.length // only when you truly guarantee non-nullrequireNotNull provides a descriptive error message, unlike !! which gives a generic NPE.
Null Safety and Java Interop
Section titled “Null Safety and Java Interop”Platform Types
Section titled “Platform Types”When calling Java code, Kotlin does not know whether a value can be null. Such types are called platform types and represented as String! (in the IDE). The compiler allows both nullable and non-null usage:
// Javapublic String getName() { return null; }// Kotlin — both compile, but one may NPE at runtimeval name: String = getName() // risky: may NPEval name: String? = getName() // safe: treat as nullable@Nullable and @NotNull Annotations
Section titled “@Nullable and @NotNull Annotations”Annotate Java APIs to help Kotlin’s compiler:
import org.jetbrains.annotations.NotNull;import org.jetbrains.annotations.Nullable;
@NotNullpublic String getName() { return name; }
@Nullablepublic String getNickname() { return nickname; }Supported annotation packages:
org.jetbrains.annotations(JetBrains)javax.annotation(JSR-305)androidx.annotation(Android)lombok.NonNull(Lombok)
Without annotations, Kotlin assumes the worst — the value could be null.
Contracts
Section titled “Contracts”Smart Casts After Null Checks
Section titled “Smart Casts After Null Checks”Kotlin performs smart casts after null checks:
val name: String? = getName()
if (name != null) { println(name.length) // name is smart-cast to String (non-null)}The compiler tracks null-check flow and automatically casts inside the safe branch.
Compiler Contracts
Section titled “Compiler Contracts”Contracts tell the compiler about function behaviour. The standard library uses them internally:
public inline fun <T> T?.let(block: (T) -> R): R { contract { returns() implies (this@let != null) } return block(this)}returns() implies (this@let != null) tells the compiler: after let returns, the receiver is non-null. This is why smart casts work inside let.
Custom contracts:
fun String?.requireNonEmpty(): String { contract { returns() implies (this@requireNonEmpty != null) } require(!this.isNullOrEmpty()) { "string must be non-empty" } return this!!}Limitations
Section titled “Limitations”Contracts have limitations:
- They only work with
inlinefunctions. - They cannot express complex relationships (e.g., “if the function returns false, then the parameter is null”).
contractblocks are experimental in some compiler versions.
Collections and Null
Section titled “Collections and Null”emptyList() vs listOf(null)
Section titled “emptyList() vs listOf(null)”val empty: List<String> = emptyList() // empty, non-null listval withNull: List<String?> = listOf(null) // list with one null elementval mixed: List<String?> = listOf("a", null, "b")emptyList<String>() is an empty list of non-null strings. listOf(null) is a list of nullable strings containing one element.
filterNotNull
Section titled “filterNotNull”Removes null elements and returns a List<T> (non-null):
val items: List<String?> = listOf("hello", null, "world")val nonNull: List<String> = items.filterNotNull() // ["hello", "world"]mapNotNull
Section titled “mapNotNull”Maps and filters in one pass — drops entries where the transform returns null:
val names: List<String> = users.mapNotNull { it.name }// equivalent to: users.map { it.name }.filterNotNull()Map with Nullable Values
Section titled “Map with Nullable Values”val map: Map<String, String?> = mapOf( "name" to "Alice", "nickname" to null,)val name: String? = map["name"]Sealed Classes and When
Section titled “Sealed Classes and When”Exhaustive When with Sealed Classes
Section titled “Exhaustive When with Sealed Classes”Sealed classes restrict inheritance to a known set of subclasses. when used as an expression must be exhaustive:
sealed class Result { data class Success(val data: String) : Result() data class Error(val message: String) : Result() object Loading : Result()}
fun describe(result: Result): String = when (result) { is Result.Success -> "Data: ${result.data}" is Result.Error -> "Error: ${result.message}" Result.Loading -> "Loading..." // no else needed — exhaustive}Handling Null Branches
Section titled “Handling Null Branches”When a sealed class property is nullable, include the null check:
val result: Result? = get()
val message = when (result) { null -> "No result" is Result.Success -> "Got: ${result.data}" is Result.Error -> "Failed: ${result.message}" Result.Loading -> "Still loading"}The null branch must be handled explicitly since when is an expression and the compiler cannot prove it covers the null case.
Common Pitfalls
Section titled “Common Pitfalls”Confusing
List<String?>withList<String>?.List<String?>is a non-null list whose elements may be null.List<String>?is a nullable list whose elements are non-null.Using !! on platform types from Java. Java return values may be null at runtime regardless of how you use them in Kotlin. Always prefer safe calls or explicit null checks on Java data.
Forgetting to handle null in when expressions. When a
whenblock is used as an expression and the subject is nullable, omitting thenullbranch is a compile error. Add it explicitly.Smart cast failure after property access. Smart casts fail if the property is a
varthat could be modified between the check and usage. Use a localvalto capture it:val local = user.name // capture onceif (local != null) {println(local.length) // smart cast succeeds}Unnecessary nullable on generic types.
List<String?>vsList<String>. Mark nullability at the element level, not the collection level, unless the entire collection can be null.Overusing !! in tests.
!!is common in test code but hides bugs. PreferrequireNotNullwith a descriptive message or use test assertion libraries that give clear failure messages.Forgetting that
lateinitdoes not make a type nullable.lateinit varbypasses null safety for non-null initialization. Accessing it before assignment throwsUninitializedPropertyAccessException, not NPE.
Summary
Section titled “Summary”- Non-null types are the default in Kotlin; add
?to make a type nullable. ?.safely accesses nullable values;?:provides defaults.letscopes execute blocks on non-null receivers with smart-castit.!!forces non-null with an NPE on null — preferrequireNotNullfor better errors.- Java interop uses platform types — annotate Java APIs with
@Nullable/@NotNull. - Smart casts after null checks are powered by compiler contracts on
inlinefunctions. filterNotNullandmapNotNullremove null elements; understandList<String?>vsList<String>?.- Sealed classes guarantee exhaustive
when; handlenullbranches explicitly when nullable.
Intuition
Section titled “Intuition”Kotlin’s null safety is like a building’s fire code — it prevents disasters by catching dangerous situations at the blueprint stage rather than after the building is occupied. The safe call operator (?.) is like a circuit breaker that stops the chain of operations if any link is null. The Elvis operator (?:) is the emergency generator that kicks in when the primary power fails. The !! operator is the red button you should rarely press — it bypasses all safety checks and will crash the program if the value is actually null. Contracts are the fine print that tells the compiler exactly when it is safe to trust a non-null assertion.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Types and Variables: Nullable type declarations and type inference fundamentals.
- Control Flow: When expressions and null-safe branching patterns.
- Metaprogramming: Reflection-based null handling and contract annotations.