Polyglot
Languages Kotlin nullability
Kotlin § nullability

Nullability

Kotlin’s null safety is one of its most distinctive features — the type system distinguishes nullable (T?) and non-nullable (T) types. The compiler refuses to admit operations on nullable values without explicit unwrapping. The principal mechanisms: safe call (?.) for nullable access, Elvis operator (?:) for default values, not-null assertion (!!) for force-unwrap, smart casts for type narrowing in conditionals, and let / also / apply for scoped operations on nullable values. The lateinit modifier admits non-nullable var properties initialised after construction. Platform types (from Java) admit either nullability — the conventional Java interop boundary. The combination — type-system-level nullability, the substantial unwrapping operators, smart casts, lateinit, the platform-type integration — eliminates a substantial class of null-pointer bugs that Java admits.

Nullable vs non-nullable types

val name: String = "Alice"                         // non-nullable
val name: String = null                            // ERROR

val nullable: String? = "Alice"                    // nullable
val nullable: String? = null                       // OK

The compiler refuses to admit operations on nullable values without explicit handling:

val name: String? = "Alice"
val length = name.length                           // ERROR: name is nullable
val length = name?.length                          // OK; Int?

Safe call ?.

The ?. admits “access this if non-null”:

val name: String? = "Alice"

val length = name?.length                          // Int?
val upper = name?.uppercase()                      // String?

// Chained:
val city = user?.address?.city                     // String?
val first = list?.firstOrNull()?.uppercase()

The expression returns null if any link in the chain is null.

For methods returning Unit:

list?.add(item)                                    // no-op if list is null

Elvis operator ?:

The ?: admits “this value, or the right operand if null”:

val name: String? = null
val display = name ?: "anonymous"                  // "anonymous"

val port = config.port ?: 8080
val timeout = options.timeout ?: defaultTimeout

For throw on null:

val name = nullable ?: throw IllegalStateException("name required")
val n = parseInt(input) ?: throw NumberFormatException()

For return on null:

fun process(input: String?): Result {
    val nonNull = input ?: return Result.Empty
    return Result.Processed(nonNull.uppercase())
}

The Elvis admits substantial conciseness for default-or-bail patterns.

Force-unwrap !!

The !! extracts the value or throws NullPointerException:

val name: String? = "Alice"
val length = name!!.length                         // 5

val nilName: String? = null
val oops = nilName!!.length                        // NPE

The conventional discipline avoids !! — the alternatives (?., ?:, let, smart casts) are conventionally safer. The !! is admitted only when:

  • The value is guaranteed non-null by surrounding logic.
  • The crash is the intended behaviour on absence (rare).

Smart casts

The compiler tracks nullability through conditionals — if a variable is checked non-null, subsequent uses see the non-nullable type:

val name: String? = "Alice"

if (name != null) {
    println(name.length)                           // smart cast: name is String here
}

// In when:
when (name) {
    null -> println("nothing")
    else -> println(name.length)                   // smart cast: name is String
}

// With ?: throw:
val unwrapped = name ?: throw IllegalStateException()
unwrapped.length                                   // unwrapped is String

The mechanism admits substantial conciseness — explicit unwrapping is often unnecessary inside conditionals.

The smart cast requires the compiler to prove the value cannot be null between the check and the use:

  • Local val — admits smart cast.
  • val properties of public types — admit smart cast (no override risk).
  • var — does not admit smart cast (could be modified between check and use).
  • Open val properties — may not admit smart cast (could be overridden).

For non-smart-castable values, the conventional defence is to capture into a local:

class Foo {
    var name: String? = null

    fun process() {
        val n = name                               // capture into local
        if (n != null) {
            println(n.length)                      // smart cast on local works
        }

        // Without the capture:
        if (name != null) {
            println(name.length)                   // ERROR: cannot smart cast (var)
        }
    }
}

let, also, apply, run, with

The Kotlin scoped functions admit substantial fluent patterns with nullable values:

val name: String? = "Alice"

// let — operate on non-null:
val length = name?.let { it.length }               // Int?
name?.let { println("got $it") }

// also — side effect:
val updated = config.also { println("config: $it") }

// apply — initialise:
val builder = StringBuilder().apply {
    append("Hello")
    append(", ")
    append("World")
}

// run — block returning a value:
val result = nullable?.run {
    val processed = process()
    processed.toString()
}

// with — block on a non-null receiver:
val s = with(builder) {
    append("!")
    toString()
}

The principal differences:

FunctionArgumentReturns
letitblock result
alsoitthe receiver
applythis (implicit)the receiver
runthis (implicit)block result
withthis (implicit)block result

The conventional uses:

  • let with ?. — operate on non-null value.
  • also — log or side-effect during fluent chain.
  • apply — initialise an object after construction.
  • run / with — block-form access to an object’s members.

Treated more substantially in Standard library.

lateinit

The lateinit modifier admits non-null var properties initialised after construction:

class Service {
    lateinit var configuration: Configuration

    fun initialise(config: Configuration) {
        configuration = config
    }

    fun process() {
        // configuration is non-null here (assumed initialised)
        configuration.host
    }
}

val service = Service()
service.initialise(Configuration(...))
service.process()

The mechanism admits substantial framework-driven initialisation (DI, test setup, etc.).

The principal restrictions:

  • Must be var (not val).
  • Must be a non-nullable reference type (not primitive).
  • Must be a property (not a local variable, except in inner classes).

Accessing a lateinit property before initialisation throws UninitializedPropertyAccessException:

class Foo {
    lateinit var name: String
}

val f = Foo()
println(f.name)                                    // throws UninitializedPropertyAccessException

if (::name.isInitialized) {                        // check programmatically
    println(name)
}

Late-initialised val via lazy

For immutable late initialisation:

class Service {
    val data: List<Item> by lazy {
        loadFromDisk()                              // computed on first access
    }
}

The by lazy admits substantial deferred initialisation; treated in Property delegation.

Platform types

When calling Java code, return types are platform types — admit either nullability:

// Java code:
public class JavaApi {
    public String getName() { ... }
}

// Kotlin call:
val name = JavaApi().name                          // type: String! (platform type)
val length = name.length                           // ADMITTED — but may NPE if Java returns null

The platform type String! is shorthand for “either String or String? — Kotlin trusts the developer”.

The conventional defence is explicit annotation:

val name: String = JavaApi().name                  // assert non-null
val name: String? = JavaApi().name                 // mark as nullable

// With Java's @Nullable / @NotNull annotations:
public @Nullable String getName() { ... }          // Kotlin sees String?
public @NotNull String getName() { ... }           // Kotlin sees String

For substantial Java interop, the conventional discipline is to add @NotNull / @Nullable annotations on the Java side — admit Kotlin’s substantial type-checking.

Nullable types and generics

val list: List<String?> = listOf("a", null, "c")  // list of nullable Strings
val list: List<String>? = null                     // nullable list of Strings

// Operations:
list.size                                          // INT — list is non-null here
list[0]                                            // String? — element may be null

list.filterNotNull()                               // List<String> — non-null elements

Map access and nullability

val map: Map<String, Int> = mapOf("a" to 1)

map["a"]                                           // Int? — get returns nullable
map["a"] ?: 0                                      // Int — with default
map.getValue("a")                                  // Int — throws if missing

// MutableMap:
val mut: MutableMap<String, Int> = mutableMapOf()
mut["b"] = 2                                       // set
mut.getOrPut("c") { 0 }                            // get-or-compute-and-put

Common patterns

Validate-and-use

fun process(input: String?): String {
    val nonNull = input ?: return "default"
    require(nonNull.isNotEmpty()) { "empty input" }
    return nonNull.uppercase()
}

Optional-style return

fun find(id: Int): User? {
    return users.firstOrNull { it.id == id }
}

val user = find(42) ?: createDefault()

Chained safe calls

val country = user?.profile?.address?.country ?: "unknown"
val firstName = list?.firstOrNull()?.uppercase()

let with safe call

fun greet(name: String?) {
    name?.let { println("Hello, $it") }            // executes only if non-null
}

val length = nullable?.let { processIt(it) }       // operate on non-null

Smart cast in when

fun describe(value: Any?): String = when (value) {
    null -> "nothing"
    is String -> "string of length ${value.length}"
    is Int -> "int: ${value.toString(2)}"
    is List<*> -> "list of ${value.size}"
    else -> "other: $value"
}

lateinit with framework

class TestSuite {
    lateinit var service: UserService

    @BeforeEach
    fun setup() {
        service = UserService.mock()
    }

    @Test
    fun testSomething() {
        val result = service.fetch(42)
        // ...
    }
}

Nullable receiver for extensions

fun String?.isNullOrBlank(): Boolean = this == null || this.isBlank()

val s: String? = null
s.isNullOrBlank()                                  // true (admitted on null receiver)

The mechanism admits substantial null-safe extensions.

Required field validation

data class CreateUser(
    val name: String?,
    val email: String?,
    val age: Int?
) {
    fun toUser(): User {
        val name = name ?: throw IllegalArgumentException("name required")
        val email = email ?: throw IllegalArgumentException("email required")
        val age = age ?: throw IllegalArgumentException("age required")
        return User(name, email, age)
    }
}

Default with nullable property

data class Config(
    val host: String? = null,
    val port: Int? = null
) {
    val effectiveHost: String get() = host ?: "localhost"
    val effectivePort: Int get() = port ?: 8080
}

Builder pattern with nullable

class HttpRequestBuilder {
    var url: String? = null
    var method: String? = null
    var body: String? = null

    fun build(): HttpRequest {
        return HttpRequest(
            url = url ?: throw IllegalStateException("url required"),
            method = method ?: "GET",
            body = body
        )
    }
}

as? for nullable cast

val any: Any = "hello"

val s: String? = any as? String                    // null on cast failure
val s: String = any as? String ?: "default"

val len = (any as? String)?.length ?: 0

Combined null check and processing

fun processOrSkip(items: List<Item?>?) {
    items?.filterNotNull()?.forEach { process(it) }
}

requireNotNull / checkNotNull

fun process(input: String?) {
    requireNotNull(input) { "input must not be null" }
    // input is String here (smart cast)
    println(input.length)
}

fun verify(state: State?) {
    checkNotNull(state) { "state was not set" }
    // state is State here
}

The requireNotNull throws IllegalArgumentException; checkNotNull throws IllegalStateException.

?.let for transformations

val email: String? = "alice@example.com"

val emailHash = email?.let { md5(it) }             // String?
val emailHashOrEmpty = email?.let { md5(it) } ?: ""

// Chained:
val processed = input?.trim()
    ?.takeIf { it.isNotEmpty() }
    ?.uppercase()
    ?.let { sanitize(it) }

takeIf / takeUnless

val n: Int? = 42

val positive = n?.takeIf { it > 0 }                // Int?: 42 if positive, null otherwise
val negative = n?.takeUnless { it > 0 }            // Int?: null if positive, n otherwise

val even = n?.takeIf { it.isMultiple(of: 2) } ?: 0

The mechanism admits substantial conditional retention.

A note on isInitialized

For lateinit properties, the ::name.isInitialized admits checking initialisation state:

class Service {
    lateinit var data: Data

    fun process() {
        if (::data.isInitialized) {
            useData(data)
        } else {
            initialiseData()
        }
    }
}

The mechanism is conventional in framework-driven initialisation patterns.

A note on the conventional discipline

The contemporary Kotlin nullability advice:

  • Use non-nullable types by default — admit nullability only when required.
  • Use ?. for safe access on nullable receivers.
  • Use ?: for default values or early return.
  • Avoid !! — only when null is impossible.
  • Use smart casts — let the compiler narrow.
  • Use let with ?. for null-safe operations.
  • Use lateinit for framework-initialised properties.
  • Use by lazy for deferred immutable initialisation.
  • Use requireNotNull / checkNotNull for assertions.
  • Use filterNotNull() to remove nulls from collections.
  • Annotate Java APIs with @Nullable / @NotNull for clean Kotlin interop.

The combination — type-system-level nullability, the substantial unwrapping operators (?., ?:, !!), smart casts in conditionals, lateinit for late initialisation, let for scoped operations, the platform-type interop — is the substance of Kotlin’s null-safety mechanism. The discipline eliminates substantial classes of null-pointer bugs at compile time; the cost is some additional type-system surface around values that may be absent.