Null safety

Model missing values with nullable types, smart casts, safe calls, and Elvis, then keep NPEs out of Java and generated-code boundaries.

level beginner time 15 min at Standard depth
version Kotlin 2.4.10
what

Kotlin puts nullability in the type: T means a non-null value, while T? means the value may also be missing.

trap

?. only propagates null safely; it does not decide whether absence is valid, while !! postpones the check until runtime.

fix

Preserve nullability at input boundaries, then narrow validated data into non-null domain values with smart casts, an early Elvis return, or an explicit result type.

What it is and why it exists

Kotlin null safety makes absence part of static type information. A plain String cannot hold null; the nullable type String? can hold either a string or null. A function signature therefore tells callers whether absence belongs to the contract, without relying on a comment or waiting until dereference to find out.

This design moves many null-pointer errors to compile time. If a receiver is String?, the compiler rejects a direct read of length; code must first prove the value is non-null, use a safe call, or explicitly accept the risk of a not-null assertion. It reduces unchecked dereferences, not every possible NullPointerException.

You meet nullable types when database columns, JSON fields, lookup results, optional configuration, or Java APIs may not supply a value. The key design question is not how quickly to remove the question mark, but what null means at this boundary: a valid omission, not found, invalid data, or a broken program state. Those meanings call for different handling.

Null safety does not validate empty strings, negative numbers, stale values, or invariants between objects. String proves only that the reference is non-null, not that the text is non-blank. Validate content before entering the domain layer, and let domain types store valid states whenever possible.

How it works

Two neighboring types

For a non-null type T, T? is its nullable version with the additional value null. A non-null value can be assigned to the corresponding nullable variable, so a String value works where String? is required; assignment in the other direction must handle null first. Repeating the question mark adds no new meaning, and Kotlin has no separate T?? level.

Nullability applies to the type immediately beside it. List<String>? means the list may be missing but every element of a present list is a non-null string; List<String?> means the list exists but its elements may be missing; List<String?>? permits both. Function types also depend on parentheses: ((String) -> Int)? is a nullable function value, while (String) -> Int? is a present function that may return null.

An API should expose T? only when absence has a real meaning. A lookup can reasonably return User? when no record exists, but if an authenticated request context still models its required user as User?, every downstream step must handle a state that should be impossible. The sooner a boundary normalizes input into a valid non-null value, the smaller the state space of later code.

Checks and smart casts

An explicit check is the most direct way to narrow a type. In the true branch of if (name != null), the compiler can treat a stable name as String; if the null branch has already returned or thrown, the path after the check can also use the non-null type. This control-flow-proven narrowing is a smart cast , with no forced runtime cast in source.

A smart cast requires the compiler to guarantee that the value cannot change between the check and its use. A local val usually qualifies, and a local var may qualify when it is not modified in between or captured by a lambda that changes it. Mutable properties do not smart-cast because other code or a custom accessor could produce a different result between two reads.

For a mutable property, reading one local snapshot is usually the right repair. val email = account.email fixes the reference observed by this operation, and a non-null check on email can then dominate its use. Bypassing the compiler with account.email!! reads the property again and turns a race or changing custom getter into a runtime crash.

An is check can prove both type and non-nullness. Once value is String succeeds, value is a non-null String. If failed conversion is an ordinary branch, the safe cast value as? String returns String?; a plain as String throws ClassCastException on a type mismatch and is not null handling.

Expressions for absence

The safe call ?. reads a property or invokes a function when its receiver is non-null; otherwise the whole expression returns null. The chain order?.customer?.address?.city stops at the first null receiver. Its result usually remains nullable because the chain answers whether it can obtain a value, not what should happen when it cannot.

A safe call can also appear on the left of an assignment. If any receiver in account?.address?.city = computeCity() is null, Kotlin skips both the assignment and evaluation of computeCity() on the right. That matters when the right-hand expression logs, counts, or performs I/O; this is more than shorthand for property access.

The Elvis operator ?: returns its left side when non-null and evaluates its right side only otherwise. The right side can be a default, return, or throw, because Kotlin permits them in expression position. val user = lookup(id) ?: return NotFound cleanly ends the missing branch at function entry and leaves user non-null afterward.

value?.let { use(it) } calls the lambda only when value is non-null and returns the lambda result. That is useful for a small transformation, but it is not safer than an ordinary if; if use itself has a nullable result, the final null cannot distinguish a missing receiver from failed transformation. Use explicit branches or a typed result when the reason matters.

The not-null assertion !! treats a nullable expression as non-null and throws NullPointerException when its value is null. It can make a violated test precondition fail immediately, but on a production path it usually means an invariant was not expressed at the boundary. Prefer requireNotNull for a caller error, checkNotNull for an object-state error, or Elvis with a domain result.

The common tools have these semantics:

FormWhen non-nullWhen null or failedResult characteristic
value?.memberAccess the memberReturn nullKeeps propagating nullability
value ?: fallbackReturn valueLazily evaluate fallbackCan supply a non-null result or exit
value?.let { transform(it) }Run the transformSkip the lambdaThe lambda itself may also return null
value as? TReturn the cast TReturn nullAlso handles a type mismatch
value!!Return the non-null valueThrow an exceptionMoves proof responsibility to runtime

Absence policy is an API contract

The same null may require different return shapes in different APIs. When a cache lookup defines null as a miss, Entry? is enough; when command execution must distinguish rejection, conflict, and dependency failure, a nullable return loses information. Type design should first preserve the differences that make callers act differently, then choose syntax.

Use this sequence when deciding how to handle a nullable input:

  1. Decide whether absence is valid; if not, reject it as close to the input as possible.
  2. Decide whether valid absence has only one meaning; use plain T? only when it does.
  3. Decide whether the caller needs the reason; return a sealed result or throw a contractually specified exception when it does.
  4. Decide whether a default is fully equivalent to supplying that value explicitly; do not erase provenance with Elvis when it is not.

Common boundaries map to different shapes:

Boundary meaningSuitable shapeInformation delivered to caller
Miss with no explanation neededT?Value or no value
Absence uses an equivalent defaultT with ?:A value after fallback
Absence violates the call contractNon-null parameter or requireNotNullException with responsibility assigned
Several recoverable failuresSealed result typeExhaustive reasons and success value

A default is itself business data. Writing a missing timeout as timeout ?: 30 promises that “not configured” and “explicitly configured as 30” are equivalent for every later behavior. If the system must show inheritance, write configuration back, or audit changes, preserve whether the value was absent.

requireNotNull(value) throws IllegalArgumentException on failure and suits a bad caller argument; checkNotNull(value) throws IllegalStateException and suits the receiver or current execution phase. Both return a non-null value, so they can initialize a local directly. They communicate responsibility better than !!, but neither belongs on an ordinary not-found path.

A public function must also make its contract legible to Java callers. A String? parameter says the function must accept null from Java, while a String parameter generates a non-null check at the Kotlin entry point. A safe call inside the implementation cannot repair the wrong signature because compatibility and caller code generation see the signature first.

Equality and nullable booleans

Kotlin’s structural equality operator == safely compares nullable references. left == right handles the case where both sides are null without a preceding safe call, and != is equally null-safe. Referential equality === asks whether two references identify the same object and also permits null on either side, but it answers an identity question rather than value equality.

Boolean? has three states, while a condition requires Boolean. flag == true succeeds only for an explicit true, merging false and null into the other branch; flag ?: false collapses the same two states. That is correct only when the business really defines “unknown” as “no.”

Use when (flag) with separate true, false, and null branches when all three states matter. This does more than avoid a compile error: later logs, metrics, or user messages can still identify the unknown source. Mechanically changing Boolean? to Boolean and filling in false usually discards information too early in the data model.

Nullable receiver extensions still run

An extension function can declare a nullable receiver, such as fun String?.display(): String. Calling name.display() runs the body even when name is null, and this remains String? inside. This design fits a stable missing representation centralized for one type.

Writing name?.display() at the call site changes the semantics: when the receiver is null, the safe call skips the extension body and produces null. If display() was meant to turn null into placeholder text, the extra ?. bypasses that fallback. Review both the extension receiver type and the call operator.

A nullable receiver extension should not conceal a business decision that depends on context. A missing name may need a fixed marker in logs, localized text in the UI, and preserved null in persistence. Those policies belong at their respective boundaries, not inside one global string extension.

Test at least four inputs for this kind of extension: null, empty text, blank text, and an ordinary value. Also call both value.extension() and value?.extension(), because they take different paths when the receiver is null. Testing only a non-null value misses the API’s most distinctive contract.

Examples

The four programs progress from reading a nullable value to boundary normalization and a generic collection. Each file was compiled with Kotlin 2.4.10 and run on JRE 21; every following output block comes from that execution.

Narrow once, continue with a non-null value

The first program keeps its nullable input at the entrance to label. Elvis returns early when the profile is absent; after that, the local current is a non-null Profile, and only the genuinely optional bio keeps a safe-call chain.

nullable_profile.kt
data class Profile(val handle: String, val bio: String?)

fun label(profile: Profile?): String {
    val current = profile ?: return "missing profile"
    val detail = current.bio
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
        ?: "no bio"
    return "${current.handle}: $detail"
}

fun main() {
    val profiles = listOf(
        Profile("ada", "  compiler engineer  "),
        Profile("lin", null),
        null,
    )

    profiles.forEach { println(label(it)) }
}
ada: compiler engineer
lin: no bio
missing profile

takeIf also turns a blank bio into null, so ?: "no bio" handles both original absence and failed validation. That is appropriate when the two cases mean the same thing to the business; if the UI must distinguish never supplied from whitespace only, preserve separate branches.

The function returns String, so its callers do not keep handling a nullable result. That signature states that label fully resolves missing input instead of passing the decision to the next layer.

Safe call and Elvis both short-circuit

The second program demonstrates two lazy behaviors. displayCity computes a default city only when the safe-call chain produces null; a safe assignment on the left also skips its right side when no receiver exists.

safe_calls.kt
data class Address(var city: String?)
data class Account(val address: Address?)

fun defaultCity(): String {
    println("default computed")
    return "unknown"
}

fun displayCity(account: Account?): String =
    account?.address?.city ?: defaultCity()

fun main() {
    val known = Account(Address("Lyon"))
    val absent: Account? = null

    println(displayCity(known))
    println(displayCity(absent))

    var writes = 0
    absent?.address?.city = run {
        writes += 1
        "ignored"
    }
    known.address?.city = "Paris"

    println(known.address?.city)
    println(writes)
}
Lyon
default computed
unknown
Paris
0

Reading the known city does not print default computed, proving that the Elvis right side was not evaluated eagerly. The final writes value of 0 proves that the safe assignment skipped the entire run on its right when the receiver was absent.

Short-circuiting avoids useless work, but it can also hide an expected side effect. If an audit event must be written whether or not the object exists, place that operation outside the safe call and record the failure reason explicitly.

Preserve failure reasons at the boundary

The third program models conversion from a permissive external record into a domain object. Raw fields stay nullable or have an unknown type, while the parser uses a safe cast, content validation, and early returns to construct an Order with only valid non-null properties.

boundary_normalization.kt
data class RawOrder(val id: String?, val cents: Int?, val customer: Any?)
data class Order(val id: String, val cents: Int, val customer: String)

sealed interface ParseResult {
    data class Valid(val order: Order) : ParseResult
    data class Invalid(val reason: String) : ParseResult
}

fun parseOrder(row: RawOrder): ParseResult {
    val id = row.id?.trim()?.takeIf { it.isNotEmpty() }
        ?: return ParseResult.Invalid("missing id")
    val cents = row.cents?.takeIf { it >= 0 }
        ?: return ParseResult.Invalid("invalid cents")
    val customer = (row.customer as? String)
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
        ?: return ParseResult.Invalid("invalid customer")

    return ParseResult.Valid(Order(id, cents, customer))
}

fun main() {
    val rows = listOf(
        RawOrder(" A-7 ", 950, " Ada "),
        RawOrder("B-2", null, "Lin"),
        RawOrder("C-3", 400, 42),
    )

    rows.map(::parseOrder).forEach(::println)
}
Valid(order=Order(id=A-7, cents=950, customer=Ada))
Invalid(reason=invalid cents)
Invalid(reason=invalid customer)

There is no !!: each local is narrowed by an early Elvis return. The success branch can carry only a complete Order, while the failure branch preserves a field-specific reason; both are easier to use than a half-built object with several nullable properties.

as? String puts a type mismatch on the ordinary failure path instead of throwing ClassCastException. It does not validate string contents, so the later trim and takeIf remain necessary.

Distinguish nullable collections from nullable elements

The fourth program adds the generic bound T : Any, so a successful result cannot contain null. An absent tag list is defined as an empty list, but a present list with a null element is treated as corrupt data and reports the index.

nullable_collections.kt
fun <T : Any> requireAll(values: List<T?>): List<T> =
    values.mapIndexed { index, value ->
        requireNotNull(value) { "value at index $index is null" }
    }

fun normalizedTags(raw: List<String?>?): List<String> {
    val tags = raw ?: return emptyList()
    return requireAll(tags).map(String::trim)
}

fun main() {
    println(normalizedTags(listOf("red", "blue")))

    val message = runCatching {
        normalizedTags(listOf("red", null, "blue"))
    }.exceptionOrNull()?.message
    println(message)

    println(normalizedTags(null).size)
}
[red, blue]
value at index 1 is null
0

Replacing this with filterNotNull() would silently delete the corrupt element and change the output length. Do that only when discarding elements is the contract; when positions or cardinality matter, validate and report the index instead.

raw ?: return emptyList() is an explicit business decision, not a null-safety rule. If a missing list and an empty list mean different things, preserve that difference in the return type, perhaps with a sealed result, instead of mechanically calling orEmpty().

Pitfalls

Fix: First classify null as a caller error, an object-state error, or a normal branch, then use requireNotNull, checkNotNull, or an Elvis return respectively. !! communicates clearly only when the crash itself is part of a test assertion.

Fix: Use safe calls for genuinely optional tail fields; validate required intermediate objects one by one at the boundary and return a specific error. During review, list every point in the chain that can produce null and confirm that all points truly have the same business meaning.

Fix: Read the property into a local val, then check and use that snapshot. If the operation must be atomic against the latest value, a local snapshot is still insufficient; put a lock or an atomic operation inside the state owner.

Fix: Define the meaning of null separately for the container and its elements, then test cardinality, order, and indexes. Reject corrupt input; filter only when product rules explicitly permit ignoring missing items.

Fix: Inspect Java nullability annotations at the interoperability boundary, assign unannotated or untrusted results to an explicit T?, and normalize immediately. Do not substitute one passing test, an example in documentation, or a generator’s guess for real annotations and boundary tests.

Deep Java boundaries and platform types

Java boundaries and platform types

A platform type is not a non-null promise

Java source often lacks the complete nullability information Kotlin’s type system expects. The Kotlin compiler represents such Java expressions as a platform type , often displayed in diagnostics and IDEs with forms such as String!; that exclamation mark is not syntax you can write in a Kotlin type declaration.

A platform type is flexible: callers may treat the result as nullable or non-null, or access a member directly. That flexibility keeps existing Java APIs convenient to call, but it does not remove the risk. If the actual result is null, a check inserted when assigning to a non-null Kotlin variable or a later member call can still fail.

Supported annotations such as @Nullable and @NotNull let the compiler enhance a Java signature. An enhanced nullable return must be handled as T?, while an enhanced non-null return can be used as T; runtime code can still violate an annotation that disagrees with its implementation. At a third-party boundary, inspect annotations and test failure behavior with a stub that returns null.

Platform types should leave core domain logic quickly. Store the Java result first as an explicit T?, resolve the default, rejection, or error mapping, then return T or a domain result internally. That concentrates the risk in one adapter instead of making every later call guess the Java method’s convention.

Library boundaries need two-way verification

The Kotlin compiler generates runtime checks for non-null parameters and some return paths, but those checks are a contract backstop, not an input parser. When a Java caller passes null, failure generally occurs at the Kotlin method entry; the exception prevents further propagation but does not replace a caller-facing validation result or protocol error.

When exporting a Kotlin API to Java, compile a Java consumer too. Tests should cover passing null, receiving nullable returns, and generic parameters because Kotlin call sites can see richer information than Java source. Exercising the same API only from Kotlin does not prove that its interoperability signature is usable or correctly annotated.

When importing from Java, inspect nullability annotations on declarations, type parameters, and the override chain. An implementation may inherit an interface contract, and a library update may enhance what used to be a platform type; warnings or type errors can change after recompilation. Adapter tests should lock in the behavior you rely on, not the ! an IDE happened to show.

For reflection and serialization frameworks, also test whether they bypass constructors, property setters, or parameter checks. A statically non-null declaration does not guarantee that every object-creation path ran the same validation. Validate framework-produced objects as external data before handing them to domain services, keeping that uncertainty at the boundary.

Generic parameters permit nullable arguments by default

A Kotlin type parameter with no written upper bound has the default upper bound Any?. Therefore fun <T> keep(value: T): T can use String? as T, and its body cannot assume value is non-null. Declare T : Any when nullable type arguments must be rejected, as requireAll does in the example.

T : Any constrains the type argument, while T & Any denotes a definitely non-nullable type. The latter requires T to have a nullable upper bound and mainly serves overrides of annotated non-null generic Java members. Pure Kotlin APIs usually need only an ordinary bound and type inference; do not scatter intersection syntax merely to look stricter.

Type erasure does not restore element nullability at runtime either. An external framework can deliver null elements across a boundary statically declared as List<String>, especially through Java, reflection, or an unsafe cast. Validate the structure and elements of untrusted data before they enter a domain model.

Null-pointer failures that remain

Kotlin documentation lists explicit throw NullPointerException(), !! on null, inconsistent data during initialization, and Java interoperability among runtime sources. Reading a lateinit property before assignment throws UninitializedPropertyAccessException; it avoids a nullable property but does not prove that initialization order is correct.

Null safety is a compile-time contract, not a sanitizer for runtime object graphs. Reflection, serializers, Java implementations, and concurrent state can all cross static assumptions. Boundary tests should deliberately supply null and assert a meaningful failure near its source instead of an accidental dereference much later.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?