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!
?
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
}
}
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
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
- Write a function that safely extracts the first character of a nullable string
- Create a function that combines two nullable strings with a default separator
- Implement a safe division function that returns null for division by zero
- Write a function that safely parses a nullable string to an integer with a default value
- 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
- What operator makes a type nullable in Kotlin?
- What does the safe call operator (?.) do when called on null?
- What is the purpose of the Elvis operator (?:)?
- When should you use the not-null assertion (!!) operator?
Show answers
- The
?
operator (e.g.,String?
) - It returns
null
instead of throwing an exception - To provide a default value when the left side is
null
- Only when you're absolutely certain the value isn't null, or when working with legacy APIs. Use sparingly!