Null Safety

The type system tells you where null can happen, so a whole category of runtime crashes becomes a compile error instead.

?. ?: !! as? Smart casts lateinit lazy Platform types
๐Ÿšซ

The Nullable Type Split

Tony Hoare called the null reference his "billion-dollar mistake": a value that type-checks as any object but can, at runtime, be nothing. Kotlin's answer isn't to remove null โ€” it still exists, because the JVM and every Java library underneath you traffics in it โ€” but to make nullability part of the type itself. String and String? are different types. The compiler tracks which one you have at every point in the program, and it refuses to compile code that dereferences a String? without first proving it isn't null.

Non-null by default, nullable is opt-in Type ยท Type?
// A plain type can never hold null: the compiler guarantees it
val name: String = "Ada"
// name = null              // ERROR: null can't be a value of a non-null type

// Append ? to allow null: now the compiler forces you to handle it
val middleName: String? = null
println(middleName.length) // ERROR: middleName might be null

// You must prove non-null before calling a member on a nullable type
if (middleName != null) {
    println(middleName.length) // fine: compiler knows it's not null here
}
๐Ÿ’ก
This is a compile-time guarantee, not a runtime check inserted everywhere. A String parameter genuinely cannot be null when your Kotlin code calls it โ€” the compiler rejects any call site that would pass one. Runtime NPEs still happen at the seams: unchecked Java interop, !!, or explicit throws. Those seams are exactly what the rest of this page is about.
๐Ÿน
Coming from Go: Go sidesteps this with zero values โ€” a nil *User pointer and a valid one have the same type, so the compiler can't stop you from dereferencing a nil one. Kotlin's String? vs String distinction is closer to what you'd get from a hypothetical Go where every pointer type came in a "might be nil" and "definitely not nil" flavor, checked at compile time.
โ“

Safe Call: ?.

The safe call operator ?. calls a member only if the receiver isn't null; if it is, the whole expression short-circuits to null instead of throwing. It's the workhorse of null handling โ€” most nullable values in real code get read through a chain of ?. rather than an explicit if check, because the result composes: a safe call on a safe call just propagates null further down the chain.

Basic safe calls ?.
val name: String? = null

// ?. evaluates to null instead of throwing when the receiver is null
val length: Int? = name?.length
println(length) // null

val name2: String? = "Ada"
println(name2?.length) // 3

// The result of a safe call is itself nullable, even if the
// member being called returns a non-null type
val upper: String? = name?.uppercase()
Chaining through nested nullables Chaining
class Address(val city: String?)
class User(val address: Address?)

val user: User? = User(Address("Berlin"))

// Chain as many ?. as needed: null anywhere in the chain
// short-circuits the whole expression to null
val city: String? = user?.address?.city
println(city) // Berlin

val missing: User? = null
println(missing?.address?.city) // null, no exception

// ?.let { } runs the block only when the receiver is non-null,
// and gives you a smart-cast non-null value inside it
city?.let { c -> println("Lives in $c") }
๐Ÿ’ก
Prefer chained ?. over nested if (x != null) { if (x.y != null) { ... } }. It reads as "follow this path if everything along it exists," which matches how you think about the data, and it avoids the pyramid of nested null checks Java code tends to accumulate.
๐ŸŽธ

Elvis Operator: ?:

Rotate :-) ninety degrees and you can just about see why ?: is called the Elvis operator. It supplies a fallback value when the expression on its left is null, and it's the natural partner to ?.: a safe-call chain gets you a nullable result, and Elvis turns that into a concrete value with a default, a thrown exception, or an early return.

Default values ?:
val name: String? = null

// If the left side is null, use the right side instead
val display = name ?: "Anonymous"
println(display) // Anonymous

// Chains naturally after a safe call
val length = name?.length ?: 0
println(length) // 0

// Right side is evaluated lazily: only runs if needed
fun expensiveDefault(): String {
    println("computing default...")
    return "fallback"
}
val value = "present" ?: expensiveDefault()
// "computing default..." never prints: left side wasn't null
return / throw as the Elvis fallback return / throw
// return and throw are expressions of type Nothing, so they're
// legal on the right of ?: โ€” this is the idiomatic guard clause
fun greet(name: String?) {
    val safeName = name ?: return  // bail out of the function early
    println("Hello, $safeName")
}

fun requireName(name: String?): String {
    return name ?: throw IllegalArgumentException("name is required")
}
๐Ÿน
Coming from Go: x ?: default is the direct answer to Go's if x == nil { x = default }, and name ?: return is the same shape as Go's if err != nil { return } guard clause โ€” just spelled as an expression instead of a statement.
โ—

Not-Null Assertion: !!

โš ๏ธ
!! converts a nullable type to its non-null counterpart by asserting, at runtime, that the value isn't null. If you're wrong, it throws a NullPointerException โ€” the exact exception Kotlin's type system exists to prevent. Every !! in a codebase is a place where the compiler's guarantee has been manually switched off.
What !! does !!
val name: String? = getNameFromCache()

// !! says "trust me, this isn't null" โ€” converts String? to String
val length: Int = name!!.length

// If getNameFromCache() actually returned null, this throws:
// java.lang.NullPointerException
//     at MainKt.main(main.kt:4)
// The stack trace points at the !!, at least, unlike Java's NPEs
// which often point at unrelated code that merely used the value.
When it's defensible Narrow uses
// Defensible: you have external knowledge the compiler can't see,
// and a violation genuinely means a programmer error, not bad input
class ViewHolder {
    private var _binding: Binding? = null
    // Non-null only between onCreateView and onDestroyView;
    // asserting here documents that lifecycle invariant
    val binding get() = _binding!!
}

// Not defensible: input you don't control
fun handleRequest(body: String?) {
    val parsed = body!!.trim()  // a malformed request now crashes the process
}
๐Ÿ’ก
Treat !! as a signal to stop and ask "what should actually happen if this is null?" Usually the honest answer is one of: a default via ?:, an early return, or a thrown exception with a message that explains why null was unexpected โ€” not a bare NPE with no context. Interviewers flag unexplained !! in review exercises for exactly this reason.
๐ŸŽญ

Safe Casts: as?

Casting has the same unsafe/safe split as null handling. as casts or throws ClassCastException on a mismatch; as? casts or produces null. Since the result of as? is nullable, it composes with everything else on this page โ€” chain it into an Elvis operator for a default, or a safe call to keep going only on success.

as vs as? Casts
val obj: Any = "hello"

// as: throws ClassCastException on mismatch
val s1: String = obj as String       // ok
// val n = obj as Int              // throws ClassCastException

// as?: null on mismatch, never throws
val n: Int? = obj as? Int          // null, no exception

// Combine with ?: for a one-line "cast or default"
val asInt: Int = (obj as? Int) ?: 0
println(asInt) // 0
โ„น๏ธ
Casting to a non-null type with as? against a nullable receiver still returns null on mismatch, but casting to a nullable type โ€” obj as? String? โ€” also accepts a null receiver. This edge case rarely matters in practice, but it explains behavior that otherwise looks inconsistent.
๐Ÿง 

Smart Casts

A smart cast is the compiler noticing that you've already proven something about a value's type or nullability, and letting you use that value as the narrower type without an explicit cast. It's flow analysis, not magic: the compiler tracks conditions like x != null or x is String along each code path and only applies the narrower type where the check is guaranteed to still hold.

  val x: String? = getValue()

  if (x != null) {
      // compiler knows: x is String here
      println(x.length)     โ† no ?. needed, no !! needed
  }
  // outside the if: x is back to String?

  Smart casts require x to be a val, or a var the compiler can
  prove nothing else could have modified between the check and use.
Smart cast from a null check != null
fun describe(x: String?) {
    if (x != null) {
        // x is smart-cast to String inside this block
        println(x.length)   // no ?. needed
    }

    // Works with early-return guards too
    if (x == null) return
    println(x.uppercase())  // x is String from here on
}
Smart cast from an is check is
fun length(x: Any): Int {
    if (x is String) {
        // x is smart-cast to String: .length is directly available
        return x.length
    }
    return 0
}

// Also works with && short-circuiting left to right
fun printIfLong(x: Any?) {
    if (x is String && x.length > 10) {
        println(x)
    }
}
โš ๏ธ
Smart casts don't work on a var declared outside the current scope (a class property, for instance), because the compiler can't rule out another thread or a called function mutating it between the check and the use. Copy it to a local val first โ€” val local = someVarProperty; if (local != null) { ... local.length ... } โ€” to get the smart cast back.
โณ

lateinit vs lazy

Both let you declare a non-null property without an initializer up front, but they solve different problems. lateinit is for a var that some external framework or setup phase will assign later โ€” dependency injection, Android's view binding, test @BeforeEach methods. lazy is for a val whose value is computed on first access and then cached โ€” an expensive computation you want deferred until it's actually needed, if ever.

lateinit: assigned later, by you lateinit
class UserService {
    // No initializer needed at declaration; you promise
    // to assign it before first use
    lateinit var repository: UserRepository

    fun init(repo: UserRepository) {
        repository = repo
    }

    fun findUser(id: Int) = repository.find(id)
}

// Reading before assignment throws:
// UninitializedPropertyAccessException

// Check assignment state without triggering the exception
val service = UserService()
if (service::repository.isInitialized) { /* ... */ }
lazy: computed once, on first access by lazy
class ReportGenerator {
    // The block runs once, on first read of `summary`.
    // Every later read returns the cached result.
    val summary: String by lazy {
        println("computing summary...")
        expensiveComputation()
    }
}

val report = ReportGenerator()
println("created")
println(report.summary) // "computing summary..." then the result
println(report.summary) // result again, no recomputation
๐Ÿ’ก
lateinit only works on a var of a non-null reference type โ€” not Int, Boolean, or other primitives, since it works by leaving the backing field temporarily uninitialized, which primitives on the JVM can't represent. lazy works on any val and is thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED); pass LazyThreadSafetyMode.NONE if you know access is single-threaded and want to skip the lock.
โ˜•

Platform Types

Java has no nullability in its type system, so when Kotlin calls Java code, it has no compile-time information about whether a returned reference can be null. Rather than guessing wrong in either direction, Kotlin marks these as platform types โ€” written String! in error messages and IDE tooltips, though you can't write that syntax yourself. A platform type can be treated as either String or String?; the compiler defers the null-safety check to you.

Calling unannotated Java Platform type
// Java method: public String getName() { ... }  (no annotation)
// Kotlin sees its return type as String! โ€” a platform type

val name = javaObject.getName()   // inferred as String, not String?
println(name.length)          // compiles fine โ€” but crashes at runtime
                                // if getName() actually returns null

// Safer: assign to an explicitly nullable type
val safeName: String? = javaObject.getName()
println(safeName?.length ?: 0)
Annotated Java is fully checked @Nullable
// Java method with JSR-305 / JetBrains annotations:
// public @Nullable String getMiddleName() { ... }
// public @NotNull String getFirstName() { ... }

// Kotlin respects the annotations directly: no platform type,
// full compile-time null checking as if it were Kotlin code
val middle: String? = javaObject.getMiddleName()  // String?
val first: String = javaObject.getFirstName()      // String
โš ๏ธ
Platform types are the most common source of NPEs in real Kotlin codebases, because the compiler stays silent โ€” the crash only shows up at the call site where you eventually dereference the value, often far from where the Java call happened. When you're not sure a Java API is annotated, treat its return as nullable until proven otherwise. See Java Interop for the full picture, including how to annotate your own Java code so Kotlin callers get proper checking.
๐Ÿ“š

Nullable Collections

Nullability on a collection type and nullability on its elements are independent, and mixing them up is an easy mistake. List<String>? is a list that might not exist at all. List<String?> is a list that definitely exists but may contain null elements. List<String?>? is both at once. Read the type left to right: the outer ? describes the container, the inner one describes what's inside it.

Distinguishing the two axes List<T>? vs List<T?>
// The list itself might be null; if present, no null elements inside
val a: List<String>? = null
val size = a?.size ?: 0

// The list always exists, but individual elements might be null
val b: List<String?> = listOf("a", null, "c")
for (item in b) {
    println(item?.uppercase() ?: "MISSING")
}

// Both: the list might be null, and any element inside might be too
val c: List<String?>? = null
filterNotNull: dropping the nulls filterNotNull
val mixed: List<String?> = listOf("a", null, "b", null, "c")

// filterNotNull() narrows List to List,
// dropping every null element
val clean: List<String> = mixed.filterNotNull()
println(clean) // [a, b, c]

// mapNotNull: transform and drop nulls from the result in one pass
val lengths = mixed.mapNotNull { it?.length }
println(lengths) // [1, 1, 1]
๐Ÿ’ก
Prefer List<T>? over List<T?> whenever you have the choice. "No list" and "empty list" already give you two ways to express absence โ€” most APIs are clearer returning an empty list than null, and clearer still never allowing null elements inside a collection whose type doesn't need to model that. Reserve List<T?> for cases where "this slot is intentionally empty" is meaningfully different from "this slot doesn't exist."
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Nullable typeString?Distinct type from String; compiler enforces checks
Safe callx?.lengthnull if x is null; otherwise the call result
Safe call + letx?.let { ... }Block runs only when x is non-null
Elvis operatorx ?: defaultRight side evaluated lazily, only if needed
Elvis guard clauseval y = x ?: returnreturn/throw are valid on the right of ?:
Not-null assertionx!!Throws NullPointerException if x is null
Unsafe castx as TThrows ClassCastException on mismatch
Safe castx as? Tnull on mismatch instead of throwing
Smart cast (null)if (x != null) { x.foo() }Requires val, or an unmodifiable var
Smart cast (type)if (x is String) { x.length }Narrows Any to String inside the block
Deferred varlateinit var x: TNon-null reference types only, no primitives
isInitialized check::x.isInitializedAvoids UninitializedPropertyAccessException
Deferred valval x by lazy { ... }Computed once, cached, thread-safe by default
Platform typeString! (tooling only)Unchecked nullability from unannotated Java
Drop nullslist.filterNotNull()List<T?> โ†’ List<T>
Map + drop nullslist.mapNotNull { ... }Transform and filter in one pass