Kotlin Null Safety Guide

10 December 2017

Kotlin Best Practices

Before Kotlin, I spent a non-trivial chunk of my debugging time chasing NullPointerException crashes. Not because I was careless — Java just didn’t give you the tools to express “this value might be absent” at the type level. You’d sprinkle @Nullable annotations, add defensive if (x != null) checks everywhere, and pray that nobody forgot one. Tony Hoare, who invented null references, famously called them his “billion-dollar mistake.” Having lived through a few years of Java Android development, I believe the estimate is low.

Kotlin’s null safety system doesn’t just reduce NPEs. It fundamentally changes how you model data. When nullability is part of the type system, you’re forced to think about absence at the point of declaration, not at the point of use. That single shift — from “check for null where you use it” to “declare whether null is possible where you define it” — eliminates entire categories of bugs.

Nullable vs Non-Nullable Types

In Kotlin, every type has two variants. String cannot be null — the compiler enforces this at compile time. String? can be null. This isn’t an annotation or a hint — it’s a type-level distinction that the compiler tracks through your entire program.

class UserProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    // Non-null: guaranteed to always have a value
    val screenTitle: String = "Profile"

    // Nullable: might be absent
    var userName: String? = null

    fun loadUser(userId: String) {
        viewModelScope.launch {
            val user = userRepository.getUser(userId)
            userName = user?.displayName
        }
    }
}

The compiler won’t let you call methods on a nullable type without handling the null case first. This sounds restrictive, but it’s exactly the constraint that prevents NPEs. Instead of finding out about null at runtime — usually in production, usually on a Friday — you handle it at compile time.

The Safe Call Operator and Chaining

The ?. operator is the workhorse of Kotlin null safety. It short-circuits to null if the receiver is null, otherwise calls the method. Where this gets powerful is chaining — you can build long safe call chains that gracefully propagate null through multiple levels.

class NotificationService(
    private val userRepository: UserRepository,
    private val pushClient: PushClient
) {

    suspend fun sendWelcomeNotification(userId: String) {
        val user = userRepository.findUser(userId)

        // Safe call chain — any null in the chain short-circuits the whole expression
        val cityName = user?.address?.city?.name

        val token = user?.deviceToken

        token?.let { validToken ->
            pushClient.send(
                token = validToken,
                title = "Welcome",
                body = "Hello, ${user?.displayName ?: "there"}!"
            )
        }
    }
}

Safe call chains are especially common when dealing with deeply nested data from APIs. A response might have response?.data?.user?.profile?.avatarUrl — instead of writing four nested if checks, you get one expression that’s null if any step fails. In a real Android app, you’ll use this pattern constantly with Intent extras, Bundle arguments, and cursor data where nested nullability is the norm.

Scope Functions With Nullables — let, run, also

let combined with ?. gives you a scoped block that only executes when the value is non-null. But let isn’t the only option — run, also, and apply all have their place depending on what you need.

class OrderProcessor(
    private val orderDao: OrderDao,
    private val analytics: AnalyticsTracker
) {

    suspend fun processOrder(orderId: String?) {
        // let — when you need to transform the non-null value
        val order = orderId?.let { id ->
            orderDao.getOrderById(id)
        }

        // run — when you need to execute a block on the non-null value
        // and return a result (like let but with 'this' instead of 'it')
        order?.run {
            analytics.trackOrderProcessed(orderId, totalAmount)
        }

        // also — when you want to do a side effect but keep the original value
        order?.also { validOrder ->
            analytics.trackEvent("order_viewed", mapOf("id" to validOrder.orderId))
        }
    }
}

I see a lot of developers overuse let for simple null checks. If all you’re doing is calling one method, ?. alone is usually cleaner. let shines when you need to do multiple things with the non-null value or when you want to avoid repeating the safe call chain. But nesting three levels of ?.let { } blocks is a code smell — at that point, you probably need an early return or a different data structure.

Smart Casts — The Compiler Is Smarter Than You Think

One of Kotlin’s best features is that the compiler tracks null checks across control flow. After you check if (x != null), the compiler knows x is non-null inside that block and automatically casts it. No explicit cast needed.

class PaymentProcessor(
    private val paymentGateway: PaymentGateway,
    private val analyticsTracker: AnalyticsTracker
) {

    fun processPayment(order: Order) {
        val discountCode = order.discountCode  // String? type

        // After this check, discountCode is smart-cast to String (non-null)
        if (discountCode != null) {
            val discount = calculateDiscount(discountCode)
            analyticsTracker.trackDiscountUsed(discountCode, discount)
        }

        // Early return pattern — also enables smart cast
        val shippingAddress = order.shippingAddress ?: run {
            analyticsTracker.trackError("No shipping address")
            return
        }

        // shippingAddress is now smart-cast to non-null
        paymentGateway.charge(order.total, shippingAddress)
    }
}

Smart casts work with if, when, and early returns. They also work with is type checks. The limitation is that smart casts only work on val properties and local variables — not var properties, because the compiler can’t guarantee a var wasn’t changed between the check and the use by another thread. If you find yourself needing to smart-cast a var, assign it to a local val first.

The Elvis Operator — Default Values Done Right

The ?: operator (named for Elvis Presley’s hairstyle, apparently) provides a default value when the left side is null. It’s concise and avoids the if (x != null) x else default boilerplate that Java developers write constantly.

class UserSettingsRepository(
    private val sharedPrefs: SharedPreferences
) {

    fun getTheme(): String {
        return sharedPrefs.getString("theme", null) ?: "system_default"
    }

    fun getMaxCacheSize(): Long {
        // Elvis with throw — fail fast when null is unexpected
        val configValue = remoteConfig.getLong("max_cache_mb")
            ?: throw IllegalStateException("max_cache_mb config missing")
        return configValue * 1024 * 1024
    }

    fun getUserDisplayName(user: User?): String {
        // Chain of fallbacks
        return user?.displayName
            ?: user?.email?.substringBefore("@")
            ?: "Anonymous"
    }
}

The Elvis operator combined with throw or return is particularly powerful. val id = args?.getString("id") ?: return is an early-exit pattern that keeps the rest of your function free from null handling. I use this constantly in Fragment onViewCreated when pulling arguments from the bundle.

The !! Operator — When and Why

The !! (non-null assertion) operator is Kotlin’s escape hatch. It tells the compiler “I know this isn’t null, trust me” and throws a KotlinNullPointerException if it is. IMO, using !! in production code is almost always a mistake. It’s reintroducing the exact problem Kotlin’s type system was designed to solve.

There are exactly two places where I think !! is acceptable. First, in test code where a null value means the test should fail anyway. Second, immediately after a framework call where you know the result is non-null but the return type is nullable due to Java interop. And even in the second case, requireNotNull() with a descriptive message is better.

requireNotNull vs checkNotNull

Both functions throw an exception when the value is null, but they communicate different intent. requireNotNull throws IllegalArgumentException — it means “this argument must not be null, the caller made a mistake.” checkNotNull throws IllegalStateException — it means “this value should not be null given the current state, something is wrong internally.”

class UserProfileFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // requireNotNull — this is a caller/navigation issue
        val userId = requireNotNull(arguments?.getString("user_id")) {
            "UserProfileFragment requires a user_id argument"
        }

        // checkNotNull — this is an internal state issue
        val binding = checkNotNull(_binding) {
            "Binding accessed after onDestroyView"
        }
    }
}

The distinction matters for debugging. When you see IllegalArgumentException in a crash log, you know to look at the calling code. When you see IllegalStateException, you know to look at the internal state management. This is a small thing, but in a production app with thousands of crashes a week, every bit of signal in the stack trace helps.

Platform Types — Where Java Meets Kotlin

This is the area that catches the most people off guard. When you call Java code from Kotlin, the return types aren’t String or String? — they’re String!, which Kotlin calls a “platform type.” The compiler doesn’t know if the value can be null because Java doesn’t have that information in its type system.

Platform types are dangerous because the compiler won’t force you to handle null. If you assign a platform type to a String variable and it’s actually null, you get an NPE at the assignment — not at the point of use. The safest approach is to always treat Java return values as nullable until you’ve verified otherwise. When wrapping a Java API, declare your Kotlin return type explicitly as nullable and handle it properly.

// Java class — no nullability annotations
// public class LegacyUserManager {
//     public String getCurrentUserId() { ... }
// }

class AuthRepository(
    private val legacyManager: LegacyUserManager
) {

    fun getCurrentUserId(): String? {
        // Treat Java return as nullable explicitly
        // Even if it's "never null," defensive coding is cheap
        return legacyManager.currentUserId
    }

    fun requireCurrentUser(): String {
        return legacyManager.currentUserId
            ?: throw IllegalStateException(
                "Expected authenticated user but got null from LegacyUserManager"
            )
    }
}

If you maintain Java libraries that Kotlin code consumes, add @Nullable and @NonNull annotations to your public API. Kotlin reads these annotations and converts platform types into proper nullable/non-nullable types. It’s a small effort that prevents a lot of bugs downstream.

Nullable Collections — A Common Trap

There’s a difference between a nullable collection and a collection of nullable elements, and mixing them up causes subtle bugs. List<String>? is a list that might not exist. List<String?> is a list that always exists but might contain null elements. List<String?>? is both.

class SearchRepository(
    private val searchApi: SearchApi
) {

    // API might return null for the entire list (no results found)
    suspend fun search(query: String): List<SearchResult>? {
        return searchApi.search(query)
    }

    // Working with nullable collections safely
    fun processResults(results: List<SearchResult>?) {
        // orEmpty() converts null to empty list — very useful
        val safeResults = results.orEmpty()

        // filterNotNull() removes null elements from List<T?>
        val validResults = results?.filterNotNull().orEmpty()

        // isNullOrEmpty() checks both conditions at once
        if (results.isNullOrEmpty()) {
            showEmptyState()
        }
    }
}

In real Android apps, nullable collections show up constantly. Cursor queries might return null, API responses might omit arrays entirely, and Bundle.getStringArrayList() returns ArrayList<String>?. The orEmpty() extension is one of the most useful Kotlin stdlib functions — it converts a nullable collection to an empty non-null one, letting you use forEach, map, filter without null checks.

Real-World Null Handling in Android

Android’s APIs are full of nullability, and knowing the common patterns saves a lot of debugging time.

Intent extras — Almost everything from an Intent is nullable. intent.getStringExtra("key") returns String?. Always handle this, even if “the other Activity definitely sends it.” After process death, the Intent survives but your assumptions about who launched it might not hold.

Bundle arguments — Same story. arguments?.getString("user_id") is the safe pattern. Never use arguments!!.getString("user_id")!! — it’ll crash after process death when arguments might be reconstructed differently.

Cursor datacursor.getString(columnIndex) can return null if the column contains a SQL NULL value. When reading from Room or ContentProvider cursors, always handle null columns even if you “know” they’re NOT NULL in the schema — migrations and data corruption happen.

class IntentParser {

    fun parseDeepLink(intent: Intent): DeepLinkData? {
        // Every step is nullable — handle it gracefully
        val uri = intent.data ?: return null
        val path = uri.path ?: return null
        val segments = uri.pathSegments

        if (segments.isNullOrEmpty()) return null

        return when (segments.firstOrNull()) {
            "order" -> {
                val orderId = segments.getOrNull(1) ?: return null
                DeepLinkData.Order(orderId)
            }
            "profile" -> {
                val userId = uri.getQueryParameter("id") ?: return null
                DeepLinkData.Profile(userId)
            }
            else -> null
        }
    }
}

The Reframe — Null Safety Is About Modeling, Not Checking

Here’s the insight that took me a while to arrive at: Kotlin’s null safety isn’t really about preventing NullPointerException. It’s about forcing you to model your data correctly. When you declare a property as String?, you’re saying “absence is a valid state for this value.” When you declare it as String, you’re saying “this value is always present.”

That distinction pushes you to think about your data model earlier and more carefully. Should this user always have an email, or is it optional? Can a payment exist without a shipping address, or is that invalid? In Java, these questions were answered implicitly (usually by whoever happened to set the field first). In Kotlin, they’re answered explicitly in the type declaration, and the compiler holds you to your answer.

The result isn’t just fewer crashes. It’s code that communicates intent more clearly, data models that match business rules more accurately, and a codebase where null is a conscious decision rather than an accidental omission.

Thanks for reading!