skip to content

In Kotlin's type system, what is the relationship between a type T and its nullable counterpart T?, and why does assigning a String to a String? compile without any conversion?

level: juniorimportance: must knowfreq 70%

answer

  1. T is a subtype of T?
  2. Values of T? = values of T plus null
  3. Widening to nullable is implicit, no conversion
  4. Narrowing T? -> T is rejected (may be null)
  5. Same JVM reference, nullability is compile-time only

basics

~20 s

T is a subtype of T?. Every non-null String is also a valid String?, so a String fits anywhere a String? is expected, just like a child fits where a parent is asked for. No conversion happens.

solid answer

~40 s

Each non-nullable type T is a strict subtype of its nullable form T?. The set of values of T? is exactly the values of T plus the single value null, so T's values are a subset of T?'s values. Because subtype-to-supertype assignment is always allowed (Liskov substitution), `val a: String? = "hi"` compiles with no boxing, wrapping, or conversion — the runtime representation is identical; nullability is a compile-time-only distinction the compiler tracks. The reverse (`val s: String = a`) is rejected because a String? may hold null, which String forbids. This T <: T? edge is the foundational link of the whole nullable lattice and is why widening to nullable is implicit while narrowing requires a smart cast or operator.

code

kotlin · 5 lines
kotlin
val s: String = "hi"
val n: String? = s      // OK, T <: T? widening, no conversion
// val back: String = n  // compile error: String? not a subtype of String
fun len(x: String?) = x?.length
len(s)                   // passing String where String? is expected: fine

go deeper

for a junior

Knows T is a subtype of T? and that a non-null value can be used where nullable is expected without conversion.

for a middle

Explains it via value sets (T's values are a subset of T?'s) and notes the runtime representation is identical for reference types.

for a senior

Frames it as the foundational lattice edge enabling implicit widening and explains why narrowing is rejected via Liskov substitution.

for a principal

Connects the edge to overall lattice design, primitive boxing nuance for Int?, and how the compiler models nullability as a compile-time-only distinction.

## The core relationship: T <: T? For any type `T`, Kotlin defines a **nullable** type `T?`. The key fact is a **subtype relationship**: `T` is a subtype of `T?` (written `T <: T?`). 'Subtype' means: anywhere a value of the supertype is expected, a value of the subtype is accepted. Why is `T <: T?`? Think in terms of the **set of values** each type can hold: - `String` can hold any string: `""`, `"hi"`, etc. - `String?` can hold all of those **plus** the special value `null`. So the values of `String` are a *subset* of the values of `String?`. A type whose values are a subset is a subtype. Hence every non-null `String` already *is* a legal `String?`. ## Why assignment needs no conversion ```kotlin val s: String = "hi" val n: String? = s // OK: widening T -> T?, implicit ``` There is **no boxing, no wrapper object, no runtime conversion**. Nullability is tracked by the compiler at compile time; at the JVM level both `s` and `n` are the same reference. The compiler simply *permits* `n` to also be `null` later. The reverse is rejected: ```kotlin val back: String = n // ERROR: String? is not a subtype of String ``` Because `n` could be `null`, and `String` excludes `null`, you cannot widen-then-narrow implicitly. ## How this scales the lattice This single edge `T <: T?` is repeated for every type, producing pairs like `Int <: Int?`, `List<T> <: List<T>?`, etc. Combined with normal subtyping it forms the **nullable lattice**: e.g. `String <: String?` and `String <: Any` both hold, and `String? <: Any?`. ## Related keywords - The `?` suffix marks a type as nullable. - A *non-null assertion* or smart cast is needed to go the other way (those operators belong to null-safety, not covered here). - `Nothing` sits below everything as the bottom type, including below every `T`. In short: `T? ` is `T` *widened* to also admit `null`, and widening (subtype to supertype) is always implicit.

  • Is there any runtime representation difference between String and String? on the JVM?
    No. For reference types the bytecode is identical; nullability is compile-time metadata. (Primitives like Int may differ because Int? must box to java.lang.Integer, but the subtype relation still holds.)
  • If T <: T?, why can't I assign a String? to a String directly?
    Subtyping only permits widening (subtype -> supertype). String? is the supertype here, so going String? -> String is narrowing and unsafe, since the value might be null.

T? is a box that holds everything T holds plus an 'empty' marker — every full box already qualifies as 'box-or-empty'.

saying these in an interview costs you the question

  • Claiming T? is a subtype of T (the direction is reversed)
  • Saying assigning T to T? performs a wrapping/boxing conversion for reference types
  • Believing String and String? are unrelated, separate types
  • Thinking the reverse assignment T? -> T is allowed without a check

context