When working with deeply nested nullable data in Kotlin, the goal is to keep code both safe and understandable without building long, fragile chains or overusing !!.
1. Avoid very long safe-call chains when they hide meaning
This is safe:
val city = user?.profile?.address?.city ?: "Unknown"
For simple reads, that is perfectly fine.
But if the chain becomes long or has business meaning, split it into named intermediate values:
val profile = user?.profile
val address = profile?.address
val city = address?.city ?: "Unknown"
This is often easier to debug and read, especially when each level has meaning.
2. Prefer early returns for required nested values
If several nested values are required, avoid deeply nested let blocks:
fun sendEmail(user: User?) {
val email = user?.profile?.contact?.email ?: return
val name = user.profile.name ?: "there"
emailService.send(to = email, subject = "Hello $name")
}
This is usually clearer than:
user?.profile?.contact?.email?.let { email ->
user.profile.name?.let { name ->
emailService.send(to = email, subject = "Hello $name")
}
}
Use return, continue, break, or throw with Elvis when absence should stop processing:
val id = request?.user?.id ?: return
val token = request.auth?.token ?: throw IllegalArgumentException("Missing token")
3. Use let sparingly and name the value
let is useful when you want to run code only if a value is non-null. For nested data, avoid stacking anonymous its.
Prefer this:
user?.profile?.contact?.email?.let { email ->
sendVerificationEmail(email)
}
Avoid this:
user?.let {
it.profile?.let {
it.contact?.let {
it.email?.let {
sendVerificationEmail(it)
}
}
}
}
Nested it quickly becomes unreadable. Use explicit names:
user?.let { user ->
user.profile?.let { profile ->
profile.contact?.let { contact ->
contact.email?.let { email ->
sendVerificationEmail(email)
}
}
}
}
Even then, if nesting grows, early returns are usually better.
4. Use defaults at the boundary
If your app can safely treat missing nested data as a default, normalize it early.
val displayName = user?.profile?.displayName ?: "Guest"
val avatarUrl = user?.profile?.avatarUrl ?: DEFAULT_AVATAR_URL
val roles = user?.permissions?.roles.orEmpty()
For collections, orEmpty() is especially readable:
for (order in user?.orders.orEmpty()) {
process(order)
}
This avoids repeated null checks.
5. Convert messy external data into clean internal models
Deep nullability often comes from APIs, databases, JSON, or maps. Instead of spreading null handling throughout your code, convert once near the boundary.
data class ApiUser(
val profile: ApiProfile?
)
data class ApiProfile(
val displayName: String?,
val email: String?
)
data class User(
val displayName: String,
val email: String?
)
fun ApiUser.toDomain(): User {
return User(
displayName = profile?.displayName ?: "Guest",
email = profile?.email
)
}
Then the rest of your code works with a cleaner model:
fun render(user: User) {
println(user.displayName)
}
This improves both performance and readability because null checks are centralized.
6. Avoid !! in nested data
This is fragile:
val city = user!!.profile!!.address!!.city!!
It may be short, but it is not safe. If any level is null, it crashes with little context.
If the value is truly required, fail with a meaningful message:
val city = user?.profile?.address?.city
?: error("User city is required")
or:
val city = requireNotNull(user?.profile?.address?.city) {
"User city is required"
}
Use this when null means a programmer error or invalid state.
7. Prefer mapNotNull and filterNotNull for nested collections
For nested nullable values in collections, avoid manual loops with multiple checks.
val emails = users
.mapNotNull { user -> user.profile?.contact?.email }
For nullable lists:
val emails = users
.orEmpty()
.mapNotNull { user -> user.profile?.contact?.email }
For nullable elements:
val names = users
.filterNotNull()
.mapNotNull { user -> user.profile?.displayName }
This is concise and usually efficient enough for normal application code.
8. Be careful with repeated expensive calls
Safe-call chains are cheap when they access properties. But avoid repeating function calls that may be expensive or have side effects:
val city = repository.getUser()?.profile?.address?.city
val country = repository.getUser()?.profile?.address?.country
Better:
val address = repository.getUser()?.profile?.address
val city = address?.city
val country = address?.country
This improves performance and avoids inconsistent results if the function returns different data each time.
9. Use local variables to benefit from smart casts
Kotlin smart casts work best with stable local values.
val profile = user.profile
if (profile != null) {
println(profile.displayName)
println(profile.email)
}
This is often clearer than repeating:
println(user.profile?.displayName)
println(user.profile?.email)
Especially when you need several fields from the same nullable object.
10. For maps, distinguish missing keys from null values when needed
Nested maps can become confusing because a map lookup returns nullable values.
val city = users["ana"]?.get("address")?.get("city") ?: "Unknown"
This is fine if missing and null mean the same thing.
If they do not, check explicitly:
val userData = users["ana"]
if (userData != null && userData.containsKey("city")) {
val city = userData["city"]
println("City key exists with value: $city")
}
Practical rule of thumb
Use this progression:
- Simple optional read → safe-call chain
val value = a?.b?.c ?: default - Required value → Elvis with
return,throw, orerrorval value = a?.b?.c ?: return - Several fields from the same nullable object → local variable + null check
val profile = user?.profile ?: return println(profile.name) println(profile.email) - Collections →
orEmpty(),mapNotNull,filterNotNullval ids = users.orEmpty().mapNotNull { it.id } - Deeply nullable external data → normalize into a cleaner model early
val domainUser = apiUser.toDomain()
In short: safe-call chains are fine for simple reads, early returns are best for required nested data, named variables improve readability, and boundary mapping keeps null complexity from spreading through your code.
