Kotlin - Null Safety

Kotlin Null Safety

One of Kotlin's most powerful features is its null safety system that eliminates the dreaded NullPointerException at compile time. Learn how to work with nullable types safely and effectively.

The Billion Dollar Mistake

Tony Hoare, the inventor of null references, called them his "billion dollar mistake" due to the countless errors they've caused. Kotlin addresses this by making null safety a first-class citizen.

Java vs Kotlin Null Handling

Java (Runtime Error)

String name = null;
int length = name.length(); // NullPointerException!

Kotlin (Compile-Time Safety)

val name: String? = null
val length = name.length  // ❌ Compilation error!
Key Principle: In Kotlin, you must explicitly declare when a variable can be null using the ? operator. This forces you to handle null cases at compile time.

Nullable vs Non-Nullable Types

Non-Nullable Types (Default)

val name: String = "Alice"      // Cannot be null
val age: Int = 25              // Cannot be null
val isActive: Boolean = true   // Cannot be null

// These would cause compilation errors:
// name = null     // ❌ Null cannot be a value of a non-null type
// age = null      // ❌ Compilation error
// isActive = null // ❌ Compilation error

Nullable Types (Explicit with ?)

val optionalName: String? = null    // Can be null
val optionalAge: Int? = null        // Can be null
val optionalFlag: Boolean? = null   // Can be null

// These are valid assignments:
val userName: String? = "Bob"       // Can hold a value
val userAge: Int? = 30             // Can hold a value
val isVerified: Boolean? = true    // Can hold a value

Safe Call Operator (?. )

The safe call operator allows you to call methods or access properties on nullable types safely:

val name: String? = "Kotlin"

// Safe call - returns null if name is null
val length: Int? = name?.length
val uppercase: String? = name?.uppercase()

println(length)     // 6
println(uppercase)  // KOTLIN

// If name was null:
val nullName: String? = null
val nullLength: Int? = nullName?.length
println(nullLength) // null (not a crash!)

Chaining Safe Calls

data class Address(val street: String, val city: String?)
data class Person(val name: String, val address: Address?)

val person: Person? = Person("Alice", Address("Main St", "New York"))

// Chain safe calls
val city: String? = person?.address?.city
val cityLength: Int? = person?.address?.city?.length

println(city)       // New York
println(cityLength) // 8

// If any part of the chain is null, the result is null
val nullPerson: Person? = null
val nullCity: String? = nullPerson?.address?.city
println(nullCity)   // null

Elvis Operator (?:)

The Elvis operator provides a default value when the left side is null:

val name: String? = null
val displayName: String = name ?: "Guest"
println(displayName) // Guest

val actualName: String? = "Alice"
val finalName: String = actualName ?: "Guest"
println(finalName) // Alice

Elvis with Expressions

fun getDisplayName(firstName: String?, lastName: String?): String {
    return firstName ?: lastName ?: "Anonymous"
}

fun calculateLength(text: String?): Int {
    return text?.length ?: 0
}

fun processUser(user: User?) {
    val name = user?.name ?: return  // Early return if null
    println("Processing user: $name")
}

// Complex example
val result = complexCalculation() ?: fallbackCalculation() ?: defaultValue

Not-Null Assertion (!!)

The not-null assertion operator converts nullable types to non-nullable, but throws an exception if the value is actually null:

val name: String? = "Kotlin"
val definitelyName: String = name!!  // Converts String? to String
println(definitelyName.length)       // Safe to call .length

// ⚠️ Dangerous usage:
val nullName: String? = null
val crash: String = nullName!!  // KotlinNullPointerException!

// Use sparingly and only when you're absolutely certain the value isn't null

⚠️ Warning: Use !! Sparingly

The not-null assertion operator defeats the purpose of null safety. Use it only when you're 100% certain the value isn't null, or when working with legacy Java APIs.

Safe Casts (as?)

Safe casting returns null instead of throwing an exception if the cast fails:

val obj: Any = "Hello"

// Unsafe cast (can throw ClassCastException)
val str: String = obj as String  // Works, but risky

// Safe cast (returns null if cast fails)
val safeStr: String? = obj as? String
println(safeStr)  // Hello

val number: Any = 42
val numberAsString: String? = number as? String
println(numberAsString)  // null (not a crash!)

// Common pattern with safe cast and Elvis
fun processString(obj: Any): String {
    val str = obj as? String ?: return "Not a string"
    return str.uppercase()
}

Checking for Null

Explicit Null Checks

fun processName(name: String?) {
    if (name != null) {
        // Smart cast: name is now String (not String?)
        println(name.length)
        println(name.uppercase())
    } else {
        println("Name is null")
    }
}

// Alternative syntax
fun processName2(name: String?) {
    if (name == null) {
        println("Name is null")
        return
    }
    // Smart cast: name is now String
    println(name.length)
}

Smart Casts

Kotlin automatically casts nullable types to non-nullable after null checks:

fun demonstrateSmartCast(value: String?) {
    println(value?.length)  // Safe call needed here
    
    if (value != null) {
        // Smart cast: value is now String (not String?)
        println(value.length)     // Direct access allowed
        println(value.uppercase()) // No safe call needed
    }
}

fun processUser(user: User?) {
    if (user != null && user.name.isNotEmpty()) {
        // Both user and user.name are smart cast
        println("Hello, ${user.name}!")
        updateUserActivity(user) // user is now User, not User?
    }
}

Collection Handling with Nulls

Nullable Collections vs Collections of Nullables

// Collection itself can be null
val nullableList: List<String>? = null

// Collection exists but can contain nulls
val listWithNulls: List<String?> = listOf("Alice", null, "Bob")

// Both collection and elements can be null
val nullableListWithNulls: List<String?>? = null

Filtering Nulls

val mixedList: List<String?> = listOf("Alice", null, "Bob", null, "Charlie")

// Filter out nulls and change type to List<String>
val nonNullList: List<String> = mixedList.filterNotNull()
println(nonNullList) // [Alice, Bob, Charlie]

// Safe operations on nullable collections
val nullableNumbers: List<Int>? = listOf(1, 2, 3)
val sum = nullableNumbers?.sum() ?: 0
println(sum) // 6

Working with Nullable Properties

Nullable Class Properties

class User {
    var name: String? = null
    var email: String? = null
    var age: Int? = null
    
    fun displayInfo() {
        val displayName = name ?: "Unknown"
        val displayEmail = email ?: "No email"
        val displayAge = age?.toString() ?: "Unknown age"
        
        println("User: $displayName, Email: $displayEmail, Age: $displayAge")
    }
    
    fun isValid(): Boolean {
        return name != null && email != null && age != null
    }
}

Late Initialization (lateinit)

class DatabaseManager {
    lateinit var connection: Connection
    
    fun initialize() {
        connection = createConnection()
    }
    
    fun query(sql: String): ResultSet {
        // Throws UninitializedPropertyAccessException if not initialized
        return connection.executeQuery(sql)
    }
    
    fun isInitialized(): Boolean {
        return ::connection.isInitialized
    }
}
Teacher Note: lateinit is useful for properties that will be initialized later (like in dependency injection frameworks) but should never be null once initialized.

Practical Patterns

Input Validation

fun validateInput(input: String?): String {
    return when {
        input == null -> "Input is missing"
        input.isBlank() -> "Input is empty"
        input.length < 3 -> "Input too short"
        else -> "Valid input: $input"
    }
}

fun safeParseInt(str: String?): Int? {
    return try {
        str?.toInt()
    } catch (e: NumberFormatException) {
        null
    }
}

Default Values and Fallbacks

data class Config(
    val host: String? = null,
    val port: Int? = null,
    val timeout: Long? = null
) {
    fun getHost() = host ?: "localhost"
    fun getPort() = port ?: 8080
    fun getTimeout() = timeout ?: 30000L
}

// Builder pattern with null safety
class UserBuilder {
    private var name: String? = null
    private var email: String? = null
    
    fun name(name: String) = apply { this.name = name }
    fun email(email: String) = apply { this.email = email }
    
    fun build(): User {
        return User(
            name = name ?: throw IllegalStateException("Name is required"),
            email = email ?: throw IllegalStateException("Email is required")
        )
    }
}

Null-Safe Comparison

fun areEqual(a: String?, b: String?): Boolean {
    return a == b  // Handles null comparisons correctly
}

fun compareNullable(a: Int?, b: Int?): Int {
    return when {
        a == null && b == null -> 0
        a == null -> -1
        b == null -> 1
        else -> a.compareTo(b)
    }
}

Java Interoperability

Platform Types

// Java method returns String (might be null)
val javaString = JavaClass.getString()  // Platform type String!

// Treat as nullable for safety
val safeString: String? = javaString
val length = safeString?.length ?: 0

// Or assert non-null if you're certain
val nonNullString: String = javaString!!

// Better: Use annotations in Java code
// @Nullable String getName() -> String?
// @NonNull String getName() -> String

Best Practices

✅ Do

  • Prefer non-nullable types when possible
  • Use safe calls (?.) instead of explicit null checks
  • Use Elvis operator (?:) for default values
  • Make nullability explicit in function signatures
  • Use lateinit for properties initialized later

❌ Don't

  • Overuse the not-null assertion (!!)
  • Mix nullable and non-nullable types unnecessarily
  • Ignore null safety when calling Java code
  • Use lateinit with nullable types
Architecture Note: Kotlin's null safety system shifts error detection from runtime to compile time, significantly improving application reliability. Design APIs to minimize nullable types and use the type system to express contracts clearly.

Advanced Null Safety Patterns

Null Object Pattern

interface Logger {
    fun log(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) {
        println("LOG: $message")
    }
}

object NullLogger : Logger {
    override fun log(message: String) {
        // Do nothing
    }
}

class Service(private val logger: Logger = NullLogger) {
    fun performAction() {
        logger.log("Action performed")  // Safe, never null
    }
}

Option/Maybe Pattern with Sealed Classes

sealed class Option<out T> {
    object None : Option<Nothing>()
    data class Some<T>(val value: T) : Option<T>()
}

fun <T> T?.toOption(): Option<T> = if (this != null) Option.Some(this) else Option.None

fun <T, R> Option<T>.map(transform: (T) -> R): Option<R> = when (this) {
    is Option.None -> Option.None
    is Option.Some -> Option.Some(transform(value))
}

// Usage
val name: String? = "Alice"
val greeting = name.toOption()
    .map { "Hello, $it!" }
    .map { it.uppercase() }

when (greeting) {
    is Option.None -> println("No greeting")
    is Option.Some -> println(greeting.value)  // HELLO, ALICE!
}

Practice Exercises

  1. Write a function that safely extracts the first character of a nullable string
  2. Create a function that combines two nullable strings with a default separator
  3. Implement a safe division function that returns null for division by zero
  4. Write a function that safely parses a nullable string to an integer with a default value
  5. Create a class that manages nullable user preferences with defaults

Real-World Example

data class User(
    val id: String,
    val name: String,
    val email: String?,
    val phone: String?,
    val address: Address?
)

data class Address(
    val street: String,
    val city: String,
    val zipCode: String?
)

class UserService {
    fun getContactInfo(user: User): String {
        val email = user.email ?: "No email"
        val phone = user.phone ?: "No phone"
        val city = user.address?.city ?: "Unknown city"
        
        return "Contact: $email, $phone, City: $city"
    }
    
    fun formatAddress(user: User): String? {
        val address = user.address ?: return null
        val zipCode = address.zipCode?.let { " $it" } ?: ""
        return "${address.street}, ${address.city}$zipCode"
    }
    
    fun notifyUser(user: User, message: String): Boolean {
        return when {
            user.email != null -> {
                sendEmail(user.email, message)
                true
            }
            user.phone != null -> {
                sendSMS(user.phone, message)
                true
            }
            else -> false
        }
    }
}

Quick Quiz

  1. What operator makes a type nullable in Kotlin?
  2. What does the safe call operator (?.) do when called on null?
  3. What is the purpose of the Elvis operator (?:)?
  4. When should you use the not-null assertion (!!) operator?
Show answers
  1. The ? operator (e.g., String?)
  2. It returns null instead of throwing an exception
  3. To provide a default value when the left side is null
  4. Only when you're absolutely certain the value isn't null, or when working with legacy APIs. Use sparingly!