thecodex.expert · The Codex Family of Knowledge
Kotlin

Null Safety and Types

The billion-dollar mistake — fixed. Kotlin makes null a compile-time concern instead of a runtime crash.

Kotlin 2.x Compile-time null safety Last verified:
Canonical Definition

Kotlin's type system distinguishes nullable types (String?) from non-nullable types (String) at compile time — calling a method on a nullable type without a null check is a compile error. Smart casts, safe calls (?.), the Elvis operator (?:), and let{} make null handling concise without null pointer exceptions.

The central idea

In Java, every reference type can be null. user.getName().length() throws a NullPointerException at runtime if getName() returns null. In Kotlin, String can never be null — the compiler enforces this. If you want a variable that might be null, you must say so explicitly with String?, and then the compiler requires you to handle the null case before using the value.

Nullable types: String vs String?

Kotlinnull_safety.kt
fun main() {
    // Non-nullable: String cannot hold null — compiler enforces this
    val name: String = "Alice"
    // val name: String = null  // COMPILE ERROR: Null can not be a value of a non-null type String

    // Nullable: String? can hold null
    var nickname: String? = "Ali"
    nickname = null   // OK

    // Safe call (?.) — returns null instead of throwing if receiver is null
    val length = nickname?.length   // Int? — null if nickname is null
    println(length)   // null

    // Elvis operator (?:) — provide a default when null
    val displayName = nickname ?: "Unknown"   // "Unknown" if nickname is null
    println(displayName)   // Unknown

    // Safe call chain
    val user: String? = null
    val upper = user?.uppercase()?.trimEnd()   // null — chain short-circuits at first null

    // let: run a block only when non-null
    nickname?.let { n ->
        println("Nickname has ${n.length} chars")   // only runs if nickname != null
    }
}

Smart casts

After an is type check or a null check, the Kotlin compiler automatically casts the variable to the checked type within that scope — no explicit cast needed.

Kotlinsmart_casts.kt
fun describe(obj: Any): String {
    // Smart cast: after "is String" check, obj is automatically String in that branch
    return when (obj) {
        is String  -> "String of length ${obj.length}"   // obj is String here
        is Int     -> "Int: ${obj * 2}"                  // obj is Int here
        is List<*> -> "List with ${obj.size} elements"   // obj is List here
        else       -> "Unknown: ${obj::class.simpleName}"
    }
}

fun processNullable(s: String?) {
    // Null check smart cast
    if (s == null) return   // compiler knows s is non-null after this point

    println(s.length)   // no ?. needed — s is smart-cast to String
    println(s.uppercase())

    // also works with &&
    if (s != null && s.length > 3) {
        println(s.substring(0, 3))   // smart cast in both conditions
    }
}

fun main() {
    println(describe("hello"))    // String of length 5
    println(describe(42))         // Int: 84
    println(describe(listOf(1,2,3)))   // List with 3 elements
    processNullable("Kotlin")
    processNullable(null)
}

Sealed classes: exhaustive type hierarchies

A sealed class restricts which classes can extend it — all subclasses must be in the same package. Combined with when, this creates exhaustive pattern matching: the compiler warns if any subclass case is missing.

Kotlinsealed.kt
sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
}

fun handleResult(result: NetworkResult<String>) {
    // when on a sealed class is exhaustive — compiler warns if a case is missing
    when (result) {
        is NetworkResult.Success -> println("Got: ${result.data}")
        is NetworkResult.Error   -> println("Error ${result.code}: ${result.message}")
        NetworkResult.Loading    -> println("Loading...")
        // no else needed — all cases covered; add a new subclass and compiler warns here
    }
}

fun main() {
    handleResult(NetworkResult.Success("Hello Kotlin"))
    handleResult(NetworkResult.Error(404, "Not found"))
    handleResult(NetworkResult.Loading)
}

Platform types: Kotlin's Java interop compromise

When calling Java APIs from Kotlin, the compiler can't know whether a Java value can be null — Java's type system doesn't distinguish. These values have platform type (String! in error messages). Platform types can be assigned to either String or String?. Assigning to String and getting a null value causes a NullPointerException — the only way to get one in Kotlin. The safe practice: always assign Java return values to nullable types (String?) when unsure, or use @NonNull/@Nullable annotations in Java code, which Kotlin respects.

Kotlinplatform_types.kt
// System.getenv returns String? in Kotlin — JetBrains annotates the JDK
val path: String? = System.getenv("PATH")   // safe — treated as nullable
val home: String = System.getenv("HOME")    // risky — NPE if HOME not set

// requireNotNull and checkNotNull: fail fast with clear message
val env = System.getenv("APP_ENV")
val appEnv: String = requireNotNull(env) { "APP_ENV environment variable must be set" }

// also / let / run / apply — scope functions for null-safe operations
val user: String? = "alice"

// let: transform if non-null
val greeting = user?.let { "Hello, $it!" } ?: "Hello, stranger!"

// also: side effect while passing through
user?.also { println("Processing user: $it") }

Generics and variance: out and in

Kotlin uses declaration-site variance — the class itself declares whether its type parameter is covariant (out T) or contravariant (in T). out T: T is only produced (returned), never consumed — List<String> is a subtype of List<Any>. in T: T is only consumed (parameter), never produced — Comparator<Any> is a subtype of Comparator<String>. This is safer and more expressive than Java's use-site <? extends T> wildcards.

Commonly confused
!! is not Kotlin-safe null handling — it is an explicit request to crash. The non-null assertion operator (!!) converts a nullable type to non-null and throws NullPointerException if the value is null. Using !! widely defeats the purpose of Kotlin's null safety. It is appropriate only when you have external knowledge that the value cannot be null and want a crash rather than silent continuation. Prefer safe calls (?.), Elvis (?:), or smart casts.
val means immutable reference, not immutable object. val list = mutableListOf(1, 2, 3) — you cannot reassign list to point at a different list, but you can still call list.add(4). For truly immutable collections, use listOf(), mapOf() etc., which return read-only views. The object the val points to can still be mutated if it's a mutable type.
Sealed class vs enum class. enum class: each value is a singleton — no state per instance. sealed class: each subclass is a full class that can hold data and have multiple instances. Use enum for fixed, stateless choices (days, directions). Use sealed class for typed results with payloads (Success(data), Error(message)).

Null safety implementation: nullable types at the JVM level

On the JVM, Kotlin's nullable types are implemented without boxing for primitives — Int? compiles to Integer (boxed), while Int compiles to the JVM primitive int. For reference types, String? and String are both java.lang.String at the bytecode level — the distinction is enforced entirely by the Kotlin compiler through @Nullable/@NotNull annotations and compile-time type checking. This means Kotlin's null safety has zero runtime overhead for reference types — it is purely a compile-time guarantee. The only runtime cost is for primitive nullable types which require boxing.

Sealed classes and the Kotlin 1.5+ sealed interfaces

Kotlin 1.5 extended the sealed concept to sealed interfaces — a class can implement multiple sealed interfaces, enabling more flexible algebraic data type hierarchies than sealed classes (which use single inheritance). Kotlin 1.5 also relaxed the restriction that sealed subclasses must be in the same file — they can now be in the same package within the same compilation unit. This change was motivated by Kotlin Multiplatform, where sealed hierarchies may be split across platform-specific source sets but still need exhaustive when checks. In Kotlin 2.0's K2 compiler, exhaustiveness checking was significantly improved — it now handles more complex conditions involving smart casts and type intersections.

Specification reference

Kotlin documentation. kotlinlang.org/docs/null-safety.html. Jemerov, D. & Isakova, S. (2017). Kotlin in Action. Manning Publications. Chapter 6 (Null safety). Kotlin language specification. kotlinlang.org/spec/. Kotlin Evolution and Enhancement Process (KEEP). github.com/Kotlin/KEEP.

Sources

1
Kotlin documentation. Null safety. kotlinlang.org/docs/null-safety.html.
2
Jemerov, D. & Isakova, S. (2017). Kotlin in Action. Manning. — Chapter 6: null safety system in depth.
3
Kotlin language specification. kotlinlang.org/spec/. — Type system, nullability, variance.
4
Kotlin documentation. Sealed classes and interfaces. kotlinlang.org/docs/sealed-classes.html.
5
Kotlin documentation. Generics: in, out, where. kotlinlang.org/docs/generics.html.
Source confidence: High Last verified: Primary source: Kotlin documentation — kotlinlang.org/docs