Skip to content
ioob.dev
Go back

Kotlin Part 6 — Null Safety Advanced

· 4 min read
Kotlin Series (6/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

The Null Story Continues

In Part 1, we learned about ?. and ?:. They’re often sufficient, but as you write real-world code, more refined tools become necessary. API responses that are null, fields injected later by a framework, expensive values that only need to be computed once — in these situations, ?. alone leads to messy code.

This part explores the remaining weapons Kotlin provides for dealing with null.

Smart Casts

The Kotlin compiler is quite smart. If you perform a null check with if, it automatically treats the variable as a non-null type inside that block.

fun printLength(text: String?) {
    if (text != null) {
        // Here, text is automatically treated as String (not String?)
        println("Length: ${text.length}")
    }
}

fun main() {
    printLength("Kotlin")  // Length: 6
    printLength(null)       // Nothing is printed
}

After the text != null check, you can use text.length directly instead of text?.length. The compiler analyzes the flow and guarantees that text is not null at that point. This is called a smart cast.

Visualizing how the compiler narrows the type makes it easy to understand.

flowchart LR
    Param["text: String?"] --> Check{"if (text != null)"}
    Check -->|true branch| Narrow["text: String<br/>(narrowed to non-null)"]
    Narrow --> Use["text.length<br/>direct access"]
    Check -->|false branch| Null["text: null"]

The same principle applies with is type checks.

fun describe(obj: Any): String {
    return when (obj) {
        is String -> "String, length ${obj.length}"  // Auto-cast to String
        is Int    -> "Integer, absolute value ${Math.abs(obj)}"
        is List<*> -> "List, size ${obj.size}"
        else      -> "Unknown type"
    }
}

fun main() {
    println(describe("hello"))       // String, length 5
    println(describe(-42))           // Integer, absolute value 42
    println(describe(listOf(1, 2)))  // List, size 2
}

Inside a when branch that checks is String, obj can be used as a String. Much cleaner than Java’s instanceof followed by another cast.

However, there are cases where smart casts don’t work. Properties declared with var can be changed by another thread, so the compiler can’t provide guarantees. In such cases, use the pattern of copying to a local variable first.

class Holder(var value: String?)

fun printValue(holder: Holder) {
    val v = holder.value  // Copy to a local variable
    if (v != null) {
        println(v.length)  // Smart cast works
    }
}

as and as?

When you need explicit type conversion, use as. But if the cast fails, it throws a ClassCastException.

fun main() {
    val obj: Any = "Hello"

    val str: String = obj as String  // Succeeds
    println(str.uppercase())         // HELLO

    // val num: Int = obj as Int     // ClassCastException!
}

For safe casting, use as?. It returns null instead of throwing an exception on failure.

fun safeCast(obj: Any): Int {
    val number: Int? = obj as? Int
    return number ?: -1
}

fun main() {
    println(safeCast(42))       // 42
    println(safeCast("hello"))  // -1
}

as? combined with ?: produces clean defensive code — a pattern frequently seen in practice.

!! (Non-Null Assertion)

!! declares “I guarantee this value is not null.” If it is null, a NullPointerException is thrown.

fun main() {
    var name: String? = "Kotlin"
    println(name!!.length)  // 6

    name = null
    // println(name!!.length)  // NullPointerException!
}

Avoid using it whenever possible. The moment you use !!, you’re bypassing Kotlin’s null safety system. That said, there are situations where it’s unavoidable: in test code where “if this is null, the test itself should fail,” or when a framework is guaranteed to fill a value but the compiler doesn’t know that.

During code review, if you see !!, always ask: “Why are you certain this isn’t null here?“

lateinit

lateinit is a promise that says “there’s no value now, but it will definitely be initialized later.” It’s primarily used for dependency injection or test setup.

class UserService {
    lateinit var repository: UserRepository

    fun findUser(id: Long): User {
        return repository.findById(id)
    }
}

interface UserRepository {
    fun findById(id: Long): User
}

data class User(val id: Long, val name: String)

It shines in frameworks like Spring or Android where fields are injected after object creation — situations where you can’t set the value in the constructor.

There are a few constraints.

class Example {
    // lateinit var count: Int      // Primitive types not allowed
    // lateinit val name: String    // val not allowed
    lateinit var data: String       // Only var + reference types
}

fun main() {
    val example = Example()

    // Check initialization status
    if (::example.isInitialized) {
        println(example.data)
    }

    // Accessing before initialization?
    // println(example.data)  // UninitializedPropertyAccessException
}

lateinit can only be used with var and not with primitive types like Int or Boolean. Accessing it before initialization throws UninitializedPropertyAccessException, which is much easier to debug than the NPE you’d get with !!.

lazy

lazy serves a different purpose than lateinit. The concept is: “compute once on first access, then use the cached value from that point on.”

class ConfigLoader {
    val config: Map<String, String> by lazy {
        println("Loading config file...")
        // Heavy initialization work
        mapOf(
            "db.url" to "jdbc:mysql://localhost:3306/app",
            "db.user" to "admin"
        )
    }
}

fun main() {
    val loader = ConfigLoader()
    println("ConfigLoader created")
    println(loader.config["db.url"])  // "Loading config file..." prints at this point
    println(loader.config["db.user"]) // Uses cached value, no reloading
}

The output is:

ConfigLoader created
Loading config file...
jdbc:mysql://localhost:3306/app
admin

lazy can only be used with val and is thread-safe by default. It’s ideal for deferring heavy resource initialization until the moment it’s actually needed.

Here’s a comparison of lateinit and lazy.

Propertylateinitlazy
Keywordvar onlyval only
Initialization timingExternally, manuallyAutomatically on first access
Primitive typesNot allowedAllowed
Thread safetyNot guaranteedGuaranteed by default
Primary useDI, test setupDeferred heavy initialization

Using let and run

We briefly covered scope functions in Part 5; let’s look at them more deeply in the context of null handling.

let — Null-Safe Chaining

?.let is an idiomatic pattern that executes a block only when the value is non-null. It’s particularly useful when multiple steps of transformation are needed.

data class Address(val city: String, val zipCode: String?)
data class Company(val name: String, val address: Address?)
data class Employee(val name: String, val company: Company?)

fun getEmployeeZipCode(employee: Employee?): String {
    return employee
        ?.company
        ?.address
        ?.zipCode
        ?.let { code ->
            "Zip code: $code"
        } ?: "No zip code"
}

fun main() {
    val emp = Employee("Kim", Company("Acme", Address("Seoul", "06234")))
    println(getEmployeeZipCode(emp))   // Zip code: 06234
    println(getEmployeeZipCode(null))  // No zip code
}

The structure chains safe calls ?., performs a transformation with let at the end, and provides a default value with ?:. The intent is much clearer than nesting if-else statements.

run — Null Check + Complex Logic

run is useful when you need to bundle multiple lines of logic into a single block.

data class Config(
    val host: String?,
    val port: Int?,
    val database: String?
)

fun buildConnectionString(config: Config?): String {
    return config?.run {
        val h = host ?: "localhost"
        val p = port ?: 3306
        val d = database ?: "default"
        "jdbc:mysql://$h:$p/$d"
    } ?: "jdbc:mysql://localhost:3306/default"
}

fun main() {
    val config = Config("prod-server", 5432, "myapp")
    println(buildConnectionString(config))
    // jdbc:mysql://prod-server:5432/myapp

    println(buildConnectionString(null))
    // jdbc:mysql://localhost:3306/default
}

Inside the run block, this is the Config object, so you can access properties directly. When working with multiple fields simultaneously to produce a single result, run offers better readability than let.


The null story that started in Part 1 is now complete. Combining the basic tools of ?. and ?: with smart casts, as?, lateinit, lazy, and scope functions means your code should rarely get messy because of null.

The next part covers generics. Let’s dive deep into the type system, from type parameters to variance.

-> Part 7: Generics


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go Part 12 — Generics and Practical Patterns
Next Post
Kotlin Part 7 — Generics