Table of contents
- The Null Story Continues
- Smart Casts
- as and as?
- !! (Non-Null Assertion)
- lateinit
- lazy
- Using let and run
The Null Story Continues
In Part 1, we learned about ?. and ?:. They’re often sufficient, but as you write real-world code, more refined tools become necessary. API responses that are null, fields injected later by a framework, expensive values that only need to be computed once — in these situations, ?. alone leads to messy code.
This part explores the remaining weapons Kotlin provides for dealing with null.
Smart Casts
The Kotlin compiler is quite smart. If you perform a null check with if, it automatically treats the variable as a non-null type inside that block.
fun printLength(text: String?) {
if (text != null) {
// Here, text is automatically treated as String (not String?)
println("Length: ${text.length}")
}
}
fun main() {
printLength("Kotlin") // Length: 6
printLength(null) // Nothing is printed
}
After the text != null check, you can use text.length directly instead of text?.length. The compiler analyzes the flow and guarantees that text is not null at that point. This is called a smart cast.
Visualizing how the compiler narrows the type makes it easy to understand.
flowchart LR
Param["text: String?"] --> Check{"if (text != null)"}
Check -->|true branch| Narrow["text: String<br/>(narrowed to non-null)"]
Narrow --> Use["text.length<br/>direct access"]
Check -->|false branch| Null["text: null"]
The same principle applies with is type checks.
fun describe(obj: Any): String {
return when (obj) {
is String -> "String, length ${obj.length}" // Auto-cast to String
is Int -> "Integer, absolute value ${Math.abs(obj)}"
is List<*> -> "List, size ${obj.size}"
else -> "Unknown type"
}
}
fun main() {
println(describe("hello")) // String, length 5
println(describe(-42)) // Integer, absolute value 42
println(describe(listOf(1, 2))) // List, size 2
}
Inside a when branch that checks is String, obj can be used as a String. Much cleaner than Java’s instanceof followed by another cast.
However, there are cases where smart casts don’t work. Properties declared with var can be changed by another thread, so the compiler can’t provide guarantees. In such cases, use the pattern of copying to a local variable first.
class Holder(var value: String?)
fun printValue(holder: Holder) {
val v = holder.value // Copy to a local variable
if (v != null) {
println(v.length) // Smart cast works
}
}
as and as?
When you need explicit type conversion, use as. But if the cast fails, it throws a ClassCastException.
fun main() {
val obj: Any = "Hello"
val str: String = obj as String // Succeeds
println(str.uppercase()) // HELLO
// val num: Int = obj as Int // ClassCastException!
}
For safe casting, use as?. It returns null instead of throwing an exception on failure.
fun safeCast(obj: Any): Int {
val number: Int? = obj as? Int
return number ?: -1
}
fun main() {
println(safeCast(42)) // 42
println(safeCast("hello")) // -1
}
as? combined with ?: produces clean defensive code — a pattern frequently seen in practice.
!! (Non-Null Assertion)
!! declares “I guarantee this value is not null.” If it is null, a NullPointerException is thrown.
fun main() {
var name: String? = "Kotlin"
println(name!!.length) // 6
name = null
// println(name!!.length) // NullPointerException!
}
Avoid using it whenever possible. The moment you use !!, you’re bypassing Kotlin’s null safety system. That said, there are situations where it’s unavoidable: in test code where “if this is null, the test itself should fail,” or when a framework is guaranteed to fill a value but the compiler doesn’t know that.
During code review, if you see !!, always ask: “Why are you certain this isn’t null here?“
lateinit
lateinit is a promise that says “there’s no value now, but it will definitely be initialized later.” It’s primarily used for dependency injection or test setup.
class UserService {
lateinit var repository: UserRepository
fun findUser(id: Long): User {
return repository.findById(id)
}
}
interface UserRepository {
fun findById(id: Long): User
}
data class User(val id: Long, val name: String)
It shines in frameworks like Spring or Android where fields are injected after object creation — situations where you can’t set the value in the constructor.
There are a few constraints.
class Example {
// lateinit var count: Int // Primitive types not allowed
// lateinit val name: String // val not allowed
lateinit var data: String // Only var + reference types
}
fun main() {
val example = Example()
// Check initialization status
if (::example.isInitialized) {
println(example.data)
}
// Accessing before initialization?
// println(example.data) // UninitializedPropertyAccessException
}
lateinit can only be used with var and not with primitive types like Int or Boolean. Accessing it before initialization throws UninitializedPropertyAccessException, which is much easier to debug than the NPE you’d get with !!.
lazy
lazy serves a different purpose than lateinit. The concept is: “compute once on first access, then use the cached value from that point on.”
class ConfigLoader {
val config: Map<String, String> by lazy {
println("Loading config file...")
// Heavy initialization work
mapOf(
"db.url" to "jdbc:mysql://localhost:3306/app",
"db.user" to "admin"
)
}
}
fun main() {
val loader = ConfigLoader()
println("ConfigLoader created")
println(loader.config["db.url"]) // "Loading config file..." prints at this point
println(loader.config["db.user"]) // Uses cached value, no reloading
}
The output is:
ConfigLoader created
Loading config file...
jdbc:mysql://localhost:3306/app
admin
lazy can only be used with val and is thread-safe by default. It’s ideal for deferring heavy resource initialization until the moment it’s actually needed.
Here’s a comparison of lateinit and lazy.
| Property | lateinit | lazy |
|---|---|---|
| Keyword | var only | val only |
| Initialization timing | Externally, manually | Automatically on first access |
| Primitive types | Not allowed | Allowed |
| Thread safety | Not guaranteed | Guaranteed by default |
| Primary use | DI, test setup | Deferred heavy initialization |
Using let and run
We briefly covered scope functions in Part 5; let’s look at them more deeply in the context of null handling.
let — Null-Safe Chaining
?.let is an idiomatic pattern that executes a block only when the value is non-null. It’s particularly useful when multiple steps of transformation are needed.
data class Address(val city: String, val zipCode: String?)
data class Company(val name: String, val address: Address?)
data class Employee(val name: String, val company: Company?)
fun getEmployeeZipCode(employee: Employee?): String {
return employee
?.company
?.address
?.zipCode
?.let { code ->
"Zip code: $code"
} ?: "No zip code"
}
fun main() {
val emp = Employee("Kim", Company("Acme", Address("Seoul", "06234")))
println(getEmployeeZipCode(emp)) // Zip code: 06234
println(getEmployeeZipCode(null)) // No zip code
}
The structure chains safe calls ?., performs a transformation with let at the end, and provides a default value with ?:. The intent is much clearer than nesting if-else statements.
run — Null Check + Complex Logic
run is useful when you need to bundle multiple lines of logic into a single block.
data class Config(
val host: String?,
val port: Int?,
val database: String?
)
fun buildConnectionString(config: Config?): String {
return config?.run {
val h = host ?: "localhost"
val p = port ?: 3306
val d = database ?: "default"
"jdbc:mysql://$h:$p/$d"
} ?: "jdbc:mysql://localhost:3306/default"
}
fun main() {
val config = Config("prod-server", 5432, "myapp")
println(buildConnectionString(config))
// jdbc:mysql://prod-server:5432/myapp
println(buildConnectionString(null))
// jdbc:mysql://localhost:3306/default
}
Inside the run block, this is the Config object, so you can access properties directly. When working with multiple fields simultaneously to produce a single result, run offers better readability than let.
The null story that started in Part 1 is now complete. Combining the basic tools of ?. and ?: with smart casts, as?, lateinit, lazy, and scope functions means your code should rarely get messy because of null.
The next part covers generics. Let’s dive deep into the type system, from type parameters to variance.




Loading comments...