skip to content

Kotlin 'bakes nullability into the type system' rather than treating null as a runtime accident. What does that mean concretely, and how does the type T differ from T??

level: middleimportance: must knowfreq 80%

answer

  1. T vs T?: two distinct types, T <: T?
  2. ?. safe call, ?: Elvis, !! assert, is/!= null smart cast
  3. Smart cast needs a stable val/local
  4. Platform types String! = unknown nullability
  5. null literal : Nothing?

basics

~20 s

Every type comes in two forms: T can never hold null, T? can. The compiler tracks which one you have and refuses to call methods on a T? until you prove it is not null, so most NullPointerExceptions are caught at compile time.

solid answer

~40 s

Nullability is a first-class part of each type, not metadata. For any type `T`, `T?` is a distinct, wider type that is the union of `T` and `null`; `T` is a subtype of `T?`. The compiler forbids dereferencing a `T?` directly, so you must use `?.` (safe call, short-circuits to null), `?:` (Elvis, supply a fallback), an explicit `if (x != null)` smart cast, or `!!` (assert non-null, throws NPE if wrong). After a null check the compiler smart-casts the value to the non-null type within that scope. Platform types (`String!`) from Java carry unknown nullability and bypass these checks, which is the main place real NPEs still leak in. `lateinit` and `Nothing?` (the type of `null`) round out the model. The payoff: nullness is verified statically and is visible in every signature.

code

kotlin · 5 lines
kotlin
fun greet(name: String?) {
    // name.length  // compile error: name may be null
    println(name?.uppercase() ?: "ANON")
    if (name != null) println(name.length)  // smart-cast to String
}

go deeper

for a junior

Knows T cannot be null and T? can, and can use ?. and ?: correctly.

for a middle

Explains T as a subtype of T?, smart casts after null checks, and the role of !!/Elvis.

for a senior

Discusses platform types from Java, why smart casts fail on mutable state, and lateinit as a non-null deferred init.

for a principal

Articulates nullability-in-the-type as a design decision that moves a class of bugs to compile time and shapes API contracts and Java interop strategy (JSpecify annotations).

## Nullability is part of the type In many languages any reference can be `null`, and the type system says nothing about it. Kotlin instead encodes nullability **into each type**. For a type `T`: - `T` is the **non-nullable** type — it can never be `null`. - `T?` is the **nullable** type — its values are every `T` value *plus* `null`. So `String` and `String?` are two different types, and `String` is a *subtype* of `String?` (every non-null string is also a valid nullable string, but not vice versa). ```kotlin val a: String = "hi" // cannot be null val b: String? = null // may be null // a = null // compile error ``` ## The compiler refuses unsafe dereferences You cannot call a member on a `T?` without handling the null case. Kotlin gives you operators to do so: - **Safe call `?.`** — `b?.length` evaluates to `null` if `b` is null, otherwise to `length`. Result type is `Int?`. - **Elvis `?:`** — `b?.length ?: 0` supplies a fallback when the left side is null. - **Not-null assertion `!!`** — `b!!.length` asserts non-null and throws `NullPointerException` if it was null. Use sparingly. - **Explicit check + smart cast** — inside `if (b != null) { b.length }` the compiler **smart-casts** `b` to `String`, so plain `.length` is allowed. ```kotlin fun lenOrZero(s: String?): Int = s?.length ?: 0 ``` ## Smart casts After a `null` check (or an `is` check), the compiler narrows the type automatically within the region where it can prove the fact holds. This works for `val`s and local `var`s that cannot change underneath it; it does **not** apply to mutable properties that another thread/getter could change, where you use a local copy instead. ## Platform types — the escape hatch Values coming from Java have **platform types**, written `String!` in diagnostics. Their nullability is unknown, so the compiler relaxes checks. If you assign a Java-returned value to a non-null `String` and it was actually null, you get an NPE at the boundary. Annotating Java with `@Nullable`/`@NonNull` (or JSpecify) restores precise checking. ## Related pieces - `lateinit var` lets you declare a non-null property initialized later; accessing it before initialization throws `UninitializedPropertyAccessException`. - The literal `null` has type `Nothing?`, the bottom of the nullable side, assignable to any `T?`. ## Why it matters Because nullness lives in the type, every signature documents it and the compiler verifies it — turning a whole class of runtime `NullPointerException`s into compile-time errors.

  • Why does smart-casting sometimes fail on a mutable property even after a null check?
    A mutable (var) property could change between the check and the use (e.g., via another thread or a custom getter), so the compiler cannot prove it stays non-null; copy it to a local val first.
  • Where do NullPointerExceptions still realistically occur in Kotlin?
    Mainly at platform-type boundaries with unannotated Java, explicit !! assertions, lateinit accessed too early, and reflection.

T? is a box that may be empty; the compiler makes you check before reaching in, instead of letting you grab and get hurt.

saying these in an interview costs you the question

  • Claiming Kotlin makes NPEs impossible (platform types, !!, lateinit, Java interop still throw)
  • Saying T and T? are the same type with a flag
  • Using !! everywhere to silence the compiler
  • Not knowing smart casts require a stable val
  • Thinking ?. throws on null instead of returning null

context