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. valproperties of public types — admit smart cast (no override risk).var— does not admit smart cast (could be modified between check and use).- Open
valproperties — 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:
| Function | Argument | Returns |
|---|---|---|
let | it | block result |
also | it | the receiver |
apply | this (implicit) | the receiver |
run | this (implicit) | block result |
with | this (implicit) | block result |
The conventional uses:
letwith?.— 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(notval). - 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
letwith?.for null-safe operations. - Use
lateinitfor framework-initialised properties. - Use
by lazyfor deferred immutable initialisation. - Use
requireNotNull/checkNotNullfor assertions. - Use
filterNotNull()to remove nulls from collections. - Annotate Java APIs with
@Nullable/@NotNullfor 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.