# Null safety

Source: https://codewiki.com/kotlin/null-safety/

> - **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?` 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:

| Form | When non-null | When null or failed | Result characteristic |
| --- | --- | --- | --- |
| `value?.member` | Access the member | Return `null` | Keeps propagating nullability |
| `value ?: fallback` | Return `value` | Lazily evaluate `fallback` | Can supply a non-null result or exit |
| `value?.let { transform(it) }` | Run the transform | Skip the lambda | The lambda itself may also return `null` |
| `value as? T` | Return the cast `T` | Return `null` | Also handles a type mismatch |
| `value!!` | Return the non-null value | Throw an exception | Moves 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 meaning | Suitable shape | Information delivered to caller |
| --- | --- | --- |
| Miss with no explanation needed | `T?` | Value or no value |
| Absence uses an equivalent default | `T` with `?:` | A value after fallback |
| Absence violates the call contract | Non-null parameter or `requireNotNull` | Exception with responsibility assigned |
| Several recoverable failures | Sealed result type | Exhaustive 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.

<!-- quick -->

```kotlin
// file: 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)) }
}
```

```text
ada: compiler engineer
lin: no bio
missing profile
```

<!-- /quick -->

`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.

```kotlin
// file: 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)
}
```

```text
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.

```kotlin
// file: 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)
}
```

```text
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.

```kotlin
// file: 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)
}
```

```text
[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

> **Pitfall:** Using `!!` to remove a compile error on a production path turns an unproven invariant into a delayed `NullPointerException`, usually without domain context.

**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.

> **Pitfall:** A long `?.` chain with one generic default collapses different failures into the same result, such as displaying `unknown` for a missing account, missing address, and blank city.

**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.

> **Pitfall:** Checking and then rereading a mutable property may either prevent a smart cast or observe another value through a custom getter or concurrent mutation; generated repairs often append `!!` directly.

**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.

> **Pitfall:** `List?`, `List<T?>`, and `List<T?>?` describe different contracts; casual use of `orEmpty()` or `filterNotNull()` erases container absence and element absence respectively.

**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.

> **Pitfall:** A platform type from Java can be used as if non-null even though the Java implementation may return `null`; code with no visible nullable type can still fail at an assignment check or member call.

**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

### 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  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`, 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.

<!-- /deep -->

[Checkpoint: kotlin/null-safety](https://codewiki.com/kotlin/null-safety/#checkpoint)

## Further reading

- [Kotlin documentation source: Null safety](https://raw.githubusercontent.com/JetBrains/kotlin-web-site/master/docs/topics/null-safety.md)
- [Kotlin documentation: Type checks and casts](https://kotlinlang.org/docs/typecasts.html)
- [Kotlin documentation: Nullability annotations in Java interoperability](https://kotlinlang.org/docs/java-to-kotlin-interop.html#nullability-annotations)
- [Kotlin documentation: Definitely non-nullable types](https://kotlinlang.org/docs/generics.html#definitely-non-nullable-types)
