Null Safety in Kotlin: ?, !!, let, and Elvis Operator
1. Overview
NullPointerException (NPE) is the nightmare of every Java developer. Kotlin solves this problem right at compile-time with a null safety system built into the type system. This article helps you understand four core tools: the ? symbol to declare nullable types, safe call ?., elvis operator ?:, and the let function. After reading, you’ll know when to use which tool, and avoid the classic pitfall: sprinkling !! everywhere and still getting NPE.
2. Nullable Types and the ? Symbol
In Kotlin, every type is non-null by default. If you want a variable to be able to hold null, you must declare it explicitly with the ? symbol after the type.
class Customer(val name: String, val email: String?)
fun main() {
val customer = Customer("An", null)
println(customer.email) // null, no error because email is String?
}
name: String— can never benull, the compiler guarantees this.email: String?— can benull, the compiler forces you to handle it wherever it’s used.
If you try to call a method directly on email like customer.email.length, the compiler will report an error immediately:
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
This is the biggest advantage: errors are caught at compile-time, not when your app is running in production.
3. Safe Call ?.
?. calls a method or accesses a property only if the receiver is not null. If the receiver is null, the entire expression returns null, and no exception is thrown.
class Order(val id: Long, val customer: Customer?)
fun printCustomerName(order: Order) {
val length = order.customer?.name?.length
println("Name length: $length")
}
order.customer?.name— ifcustomerisnull, the entire expression short-circuits and returnsnull, no need to evaluate.length.- The result
lengthhas typeInt?, because every step in the?.chain can producenull.
Name length: null // when customer == null
Name length: 2 // when customer.name = "An"
Note: A chain of
?.longer than 3–4 steps is often a sign of poor domain model design. Consider extracting functions or using sealed classes instead of letting nullability spread everywhere.
3.1. Safe Call Combined with Function Calls
?. also works with functions, not just properties:
fun notifyCustomer(order: Order) {
order.customer?.let { sendEmail(it) }
}
fun sendEmail(customer: Customer) {
println("Send email to ${customer.email}")
}
The line order.customer?.let { ... } executes the let block only if customer is not null. This relates directly to the let section in part 5.
4. Elvis Operator ?:
The Elvis operator provides a default value when the expression on the left is null. The name comes from the shape of ?:, which looks like Elvis Presley’s hair in profile.
fun getDisplayEmail(customer: Customer): String {
return customer.email ?: "email not provided"
}
- If
customer.email != null, returns that value. - If it’s
null, returns the fallback string"email not provided".
The Elvis operator is even more powerful when combined with return or throw, enabling clean early-return patterns:
fun validateOrder(order: Order): Customer {
val customer = order.customer ?: throw IllegalStateException("Order ${order.id} missing customer")
return customer
}
The line ?: throw IllegalStateException(...) causes the compiler to infer customer as non-null Customer, not Customer? anymore. This is a common technique to “unwrap” nullable early in a function.
4.1. Elvis with Safe Default Functions
fun calculateDiscount(order: Order): Double {
val tier = order.customer?.name?.length ?: 0
return if (tier > 5) 0.1 else 0.0
}
Here, both the safe call chain and elvis are combined: if any step returns null, the final result falls back to 0.
5. The let Function for Handling Non-Null Values
let is a scope function: it takes a receiver, runs a block, and returns the result of that block. Combined with ?., it becomes the classic pattern for executing code only when a value is not null.
fun processPayment(amount: Double?) {
amount?.let {
println("Processing payment: $it")
chargeCard(it)
}
}
fun chargeCard(value: Double) {
println("Charged $value from card")
}
amount?.let { ... }— the block inside only runs ifamount != null.- Inside the block,
ithas typeDouble(non-null), notDouble?, so you can callchargeCard(it)without needing!!or extra checks.
Compared to writing if (amount != null) { ... }, using let helps limit the scope of temporary variables, prevents variable leakage outside the block, and reads more in a “pipeline” style.
5.1. Combining let with Elvis to Return a Value
fun formatAmount(amount: Double?): String {
return amount?.let { "Amount: %.2f".format(it) } ?: "No amount provided"
}
This line reads naturally: “if there’s an amount, format it; otherwise, use the fallback.” This is a very common idiom in real Kotlin code, especially when mapping data from DTOs to domain models.
6. Non-null Assertion !! — When to Use It?
!! forces the compiler to treat a nullable type as non-null. If the actual value is null, it throws NullPointerException immediately.
fun getCustomerNameUnsafe(order: Order): String {
return order.customer!!.name
}
order.customer!!— ifcustomerisnull, this line throws NPE with a fairly clear message:
Exception in thread "main" java.lang.NullPointerException
at MainKt.getCustomerNameUnsafe(Main.kt:2)
Note:
!!essentially brings NPE back into Kotlin by your own hand. Only use it when you are 100% certain the value is not null at that moment (for example, after validation in a previous step), and it’s best to add a comment explaining why.
When it’s acceptable to use !!:
- Interoperating with legacy Java code where the API returns
@Nullablebut you know for certain that business logic guarantees it’s not null. - In test code, when you want to fail fast if the setup is wrong.
When you should not use !!:
- Handling user input, HTTP requests, or database results — these can unexpectedly be null.
- As a substitute for validation logic. Use
requireNotNull()or elvis + exception for clearer semantics.
fun getCustomerName(order: Order): String {
val customer = requireNotNull(order.customer) { "Order ${order.id} must have a customer" }
return customer.name
}
requireNotNull both unwraps the nullable and provides a much clearer error message than plain !! — this is the preferred choice over bare !!.
7. Quick Comparison of Four Tools
| Tool | When to Use | Behavior When Null |
|---|---|---|
? |
Declare type as nullable | N/A, just a type annotation |
?. |
Safe access, uncertain about null | Returns null, no throw |
?: |
Need fallback value or early-return | Execute right-hand expression |
let |
Execute logic only when value exists | Block doesn’t run |
!! |
100% certain it’s not null | Throw NullPointerException |
8. Writing Tests for Null-Handling Functions
To ensure null-safety logic works correctly, write tests for both paths: with and without a value.
import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
class OrderServiceTest {
@Test
fun `getDisplayEmail returns fallback when email is null`() {
val customer = Customer(name = "An", email = null)
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("email not provided")
}
@Test
fun `getDisplayEmail returns email when it has value`() {
val customer = Customer(name = "An", email = "an@example.com")
val result = getDisplayEmail(customer)
assertThat(result).isEqualTo("an@example.com")
}
@Test
fun `validateOrder throws when customer is null`() {
val order = Order(id = 1L, customer = null)
assertThatThrownBy { validateOrder(order) }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("missing customer")
}
}
- The first test checks that elvis returns the fallback correctly when
email == null. - The second test checks the opposite path — the actual value isn’t lost.
- The final test checks that
?: throwthrows the right exception with the expected message, using AssertJ’sassertThatThrownBy.
Dependencies needed for testing (if using Gradle Kotlin DSL):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
9. Common Mistakes
Overusing !! to “silence the compiler”. When the compiler reports a nullable error and your reflex is to type !! immediately, stop — it’s a sign you don’t fully understand the data flow. Ask yourself: can this value really be null? If yes, handle it with ?: or let.
Mechanically using ?.let instead of if-else. For complex logic with multiple branches, if (x != null) { } else { } is sometimes clearer than nested let. Don’t force everything into elvis/let if the code becomes less readable.
Forgetting that platform types from Java have no null safety. When calling a Java method that returns an object without @Nullable/@NonNull annotations, Kotlin treats it as a platform type (String!), and the compiler won’t force you to check for null. NPE can still occur at runtime in this case — add ?. yourself or manually check when working with Java interop.
// Java: public String getLegacyName() { return null; } // no annotation
val name = legacyService.getLegacyName() // inferred type is String!, Kotlin won't warn
println(name.length) // possible NPE at runtime if name is null
10. Conclusion
Kotlin’s null safety is not “magic” that completely eliminates NPE, but a tool to catch errors earlier — at compile-time instead of runtime. The ? symbol declares nullability, ?. provides safe access, ?: supplies a fallback or early-return, and let limits the scope of logic when a value exists. As for !!, it’s an emergency escape hatch that should only be used when you’re certain and willing to accept NPE if you’re wrong. Master these four tools, and you’ll write much safer Kotlin code compared to the old Java null-check habits.