Welcome back! In the last few lessons you learned to store values, write functions and make decisions. Today you meet one of Kotlin's best ideas, the one that quietly saves Android developers from a huge number of crashes: null safety.
Every programmer eventually meets a value that is simply missing. A customer who did not give a phone number. A search that found nothing. A text box the user left empty. In many older languages, touching a missing value crashes the program with the infamous "null pointer" error, which its inventor Tony Hoare called his "billion-dollar mistake". Kotlin attacks the problem at the root: the compiler makes you deal with missing values before your app ever runs.
By the end of this lesson you will be able to:
- Explain what
nullmeans and why it causes crashes in other languages. - Declare nullable types with
?and tell them apart from non-null types. - Use the safe call operator
?.to read members of a value that might be missing. - Use the Elvis operator
?:to supply a default value. - Use
letwith?.to run code only when a value exists. - Understand smart casts after a null check.
- Know why
!!is dangerous and what to write instead. - Use
lateinit,isNullOrEmpty(),toIntOrNull(),requireNotNull()and safe casts withas?. - Recognise platform types when Kotlin meets Java code.
How this fits the bigger picture: This lesson matches the Use nullability in Kotlin codelab in Unit 2 of Google's official Android Basics with Compose course. We add more practice and more pitfalls. Links to the official page are at the end.
Why it matters for Android
Android is full of values that may be absent: the text a user typed, a saved setting that was never saved, an image that has not downloaded yet, an extra passed between screens. Every Android library you will use (Compose, Room, Retrofit) uses nullable types to say "this might not be there". If you cannot read a String?, you cannot read Android code. This lesson is that reading skill.
You should be comfortable with these ideas. If any feels shaky, revisit the lesson shown.
1. Variables and types (lesson 07)
val creates a value that cannot be reassigned, var creates one that can. Every variable has a type such as String, Int or Boolean. Kotlin usually works the type out for you (type inference), but you can write it explicitly: val city: String = "Ibadan".
2. Functions (lesson 08)
A function takes parameters and may return a value: fun double(n: Int): Int = n * 2. Parameters and return values have types too, and those types can be nullable, as you will see today.
3. Conditionals (lesson 09)
if (x > 3) { ... } else { ... } makes decisions, and if can also produce a value: val label = if (x > 3) "big" else "small". Null checks use exactly this tool.
4. Reading a compiler error (lesson 06)
You will see red underlines today on purpose. Reading the message from the first error is a skill you already started. Today's errors read things like Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?. It sounds scary, but it is simply the compiler saying "that value might be missing, please handle it".
An empty seat on a danfo bus
Imagine a danfo bus with fourteen seats. A non-null seat always has a passenger. A nullable seat might be empty. If the conductor says "ask the passenger in seat 5 for their fare" and seat 5 is empty, the conductor is talking to nobody and the whole routine breaks down. A careful conductor first looks: "Is anyone there? If yes, collect the fare. If not, move on." Kotlin forces you to be that careful conductor.
null is the value that means "nothing here". It is not zero, it is not an empty string "", and it is not false. Those are real values. null is the absence of any value.
| Value | Is it a real value? | Meaning |
|---|---|---|
0 |
Yes | The number zero |
"" |
Yes | A text with no characters |
false |
Yes | The Boolean "no" |
null |
No | There is nothing here at all |
Two families of types
In Kotlin every type comes in two flavours.
String -> can NEVER be null. The compiler guarantees a real text.
String? -> can be a text OR null. The ? means "maybe missing".
Int -> always a number
Int? -> a number or null
The question mark is part of the type. String and String? are different types, and the compiler treats them differently:
can hold null? can call .length directly?
String no yes
String? yes NO (must check first)
That last column is the heart of the lesson. Because a String is guaranteed to exist, you can call .length on it freely. Because a String? might be missing, Kotlin refuses to let you call .length until you have handled the "missing" case. A whole class of crash is removed, because the dangerous code simply does not compile.
The toolbox
Kotlin gives you a small set of tools for dealing with null. Learn what each is for and you will rarely be stuck.
| Tool | Name | What it does |
|---|---|---|
x?.length |
Safe call | If x is null, the whole expression is null. Otherwise it reads .length. |
x ?: "default" |
Elvis operator | Use x if it is not null, otherwise use the right-hand side. |
x?.let { ... } |
Safe call with let |
Run a block only when x is not null. |
if (x != null) |
Null check | Inside the if, Kotlin treats x as non-null (a smart cast). |
x!! |
Not-null assertion | "I promise it is not null." Crashes if you are wrong. Use rarely. |
lateinit var |
Late initialisation | "I will set it before I use it." For properties only. |
Think about it: Why do you think the Kotlin designers made the compiler refuse to compile unsafe code, instead of merely warning you? (Because warnings get ignored, and a crash in the hands of a user is far more expensive than an error on your own screen.)
Run each example in the Kotlin Playground at play.kotlinlang.org, exactly as in lesson 06.
Example 1: The compiler refuses null in a normal type
fun main() {
var name: String = "Tunde"
name = null
println(name)
}
Expected result: this does not run. The compiler reports an error on null:
Null can not be a value of a non-null type String
Explanation: String promises a real text, so assigning null breaks the promise. To allow null, change the type to String?.
Example 2: Declaring a nullable variable
fun main() {
var nickname: String? = null
println("Nickname: $nickname")
nickname = "Tee"
println("Nickname: $nickname")
}
Expected output:
Nickname: null
Nickname: Tee
Explanation: A nullable variable can start as null and be given a real value later. Notice that a string template happily prints the word null when the value is missing. That is useful for debugging but is rarely what you want to show a user, so you will usually replace it with a default (Example 5).
Example 3: The compiler stops an unsafe call
fun printLength(nickname: String?) {
println(nickname.length)
}
fun main() {
printLength("Tee")
}
Expected result: this does not compile, even though the call passes a real text. The compiler checks the type of the parameter, not the value that happens to arrive:
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
Explanation: Inside printLength the type is String?, so the compiler must assume the worst: some other caller might pass null. We need one of the tools in our toolbox. (A small side note: in the later examples we write things like val first: String? = "Genevieve" to keep them short. Kotlin can see you just stored a real text, so it may warn that a ?. is unnecessary. The warning is harmless. In real apps the nullable values come from function parameters, user input and libraries, where the compiler cannot know.)
Example 4: Safe call ?.
fun main() {
val first: String? = "Genevieve"
val second: String? = null
println(first?.length)
println(second?.length)
println(first?.uppercase())
println(second?.uppercase())
}
Expected output:
9
null
GENEVIEVE
null
Explanation: first?.length reads the length because first exists: "Genevieve" has nine letters. second?.length sees null and stops, producing null instead of crashing. Notice that the result of a safe call is itself nullable: first?.length has type Int?, not Int. The "maybe" spreads along with the value.
Safe calls can be chained. If any link is null, the whole chain is null:
class Address(val city: String?)
class Person(val name: String, val address: Address?)
fun main() {
val ada = Person("Ada", Address("Ibadan"))
val bola = Person("Bola", Address(null))
val chidi = Person("Chidi", null)
println(ada.address?.city)
println(bola.address?.city)
println(chidi.address?.city)
}
Expected output:
Ibadan
null
null
Explanation: Ada has an address with a city. Bola has an address but no city. Chidi has no address at all, so the chain stops at address?. and the .city is never attempted.
Example 5: The Elvis operator ?:
The name comes from the operator's resemblance to Elvis Presley's hairstyle if you tilt your head. It means "or else".
fun main() {
val nickname: String? = null
val shown = nickname ?: "No nickname"
println(shown)
val length = nickname?.length ?: 0
println("Length: $length")
val other: String? = "Kemi"
println(other ?: "No nickname")
println(other?.length ?: 0)
}
Expected output:
No nickname
Length: 0
Kemi
4
Explanation: a ?: b gives a when a is not null, otherwise b. Combining ?. and ?: is the single most common null-handling pattern in Kotlin: read it safely, and give a default if it was missing. The type of nickname?.length ?: 0 is plain Int, because the default removes the "maybe".
The right side of ?: can even be a return or a throw, which is a neat way to exit a function early:
fun describe(name: String?): String {
val safeName = name ?: return "Nobody"
return "Hello, $safeName"
}
fun main() {
println(describe("Ada"))
println(describe(null))
}
Expected output:
Hello, Ada
Nobody
Explanation: If name is null, the function returns "Nobody" immediately. Otherwise safeName is a non-null String and the rest of the function can use it freely.
Example 6: Null checks and smart casts
Sometimes the clearest approach is an ordinary if:
fun main() {
val city: String? = "Ibadan"
if (city != null) {
println("City has ${city.length} letters")
} else {
println("City unknown")
}
}
Expected output:
City has 6 letters
Explanation: Inside the if block the compiler knows city is not null, so it quietly treats it as a String and lets you write city.length without ?.. This is a smart cast: the type is narrowed automatically because you proved it. "Ibadan" has six letters.
Smart casts work when the compiler can be sure the value cannot change between the check and the use. That is true for a local val, and for a local var that nothing else modifies in between. It is not true for a var property of a class, because another part of the program could change it at any moment:
class Profile(var bio: String?)
fun main() {
val p = Profile("Learning Kotlin")
// if (p.bio != null) println(p.bio.length) // does NOT compile
val bio = p.bio // copy into a local val first
if (bio != null) {
println(bio.length) // works: bio is a local val
}
}
Expected output:
15
Explanation: The commented line fails because p.bio could become null between the check and the use. Copying into a local val freezes the value, and then the smart cast works. "Learning Kotlin" is 8 + 1 + 6 = 15 characters.
Example 7: let for "only if present"
let runs a block of code with the value as it. Combined with ?., the block runs only if the value is not null.
fun main() {
val phone: String? = "0803 000 1234"
val nothing: String? = null
phone?.let {
println("Calling $it")
}
nothing?.let {
println("This line never prints: $it")
}
println("Done")
}
Expected output:
Calling 0803 000 1234
Done
Explanation: For phone, the block runs with it standing for the text. For nothing, the safe call short-circuits and the block is skipped entirely. Inside the block it is non-null, so no further checks are needed. (Lesson 12 explains lambdas and it properly, and lesson 13 returns to let and the other scope functions. For now, remember the pattern value?.let { ... }.)
Example 8: Dangerous !!
The not-null assertion !! tells the compiler "trust me, this is not null". If you are wrong, the program crashes.
fun main() {
val a: String? = "safe"
println(a!!.length)
val b: String? = null
println(b!!.length)
}
Expected output:
4
Exception in thread "main" java.lang.NullPointerException
Explanation: The first !! is fine. The second one finds null and throws a NullPointerException, which ends the program (the exact wording of the crash report varies by tool). In an Android app this is the "Unfortunately, the app has stopped" screen your users hate.
When is !! acceptable? Almost never in app code. If you catch yourself typing !!, stop and choose instead: ?., ?:, an if check, or requireNotNull(value) { "message" }, which crashes with a helpful message when a value that must exist is missing. Kotlin has a famous rule of thumb: !! is the compiler's way of saying "I told you so".
Example 9: Helpful standard-library functions
Several everyday functions handle null for you:
fun main() {
val empty: String? = ""
val missing: String? = null
val real: String? = "Hi"
println(empty.isNullOrEmpty())
println(missing.isNullOrEmpty())
println(real.isNullOrEmpty())
println("42".toIntOrNull())
println("forty-two".toIntOrNull())
println("forty-two".toIntOrNull() ?: -1)
val thing: Any = "text"
val number = thing as? Int
println(number)
}
Expected output:
true
true
false
42
null
-1
null
Explanation:
isNullOrEmpty()is true for bothnulland"". (isNullOrBlank()also treats whitespace-only text as empty.)toIntOrNull()converts text to a number, or returnsnullinstead of crashing if the text is not a number. Compare it withtoInt(), which throws an exception on bad input. This is how you safely read what a user typed into a text field.as?is a safe cast: it returns the converted value, ornullif the conversion is impossible. Here"text"cannot become anInt, so the result isnull.
Example 10: lateinit
Sometimes a property cannot be given a value when the object is created, but you promise to set it before it is used. On Android this happens often, because the system creates your objects first and hands you the pieces later. lateinit is that promise:
class Screen {
lateinit var title: String
fun show() {
if (::title.isInitialized) {
println("Title: $title")
} else {
println("title not set yet")
}
}
}
fun main() {
val screen = Screen()
screen.show()
screen.title = "Home"
screen.show()
}
Expected output:
title not set yet
Title: Home
Explanation: lateinit var title: String is a non-null property with no starting value. If you read it before assigning it, the program crashes with UninitializedPropertyAccessException. The ::title.isInitialized check lets code ask safely, and it works only from inside the same class. Rules to remember: lateinit is for var only, cannot be used with Int, Boolean and the other basic number types, and is a promise you must keep. Prefer nullable types or normal initial values when you can.
Platform types, briefly
Kotlin can call code written in Java, and Java has no null safety. When Kotlin receives a value from Java it cannot know whether it can be null, so it labels the type with an exclamation mark in tooltips, such as String!. This is called a platform type. Kotlin lets you treat it as either String or String?, but if you pick String and the value is secretly null, you get a crash. Many Android framework functions return platform types. The safe habit: when a Java or Android API gives you a value that could plausibly be missing, assign it to an explicit nullable type (val s: String? = ...) and handle it.
When you meet a nullable value, ask yourself what you want to happen when it is missing.
| If, when the value is missing, you want to... | Use |
|---|---|
| Get a default value | value ?: default |
| Skip some work | value?.let { ... } or if (value != null) { ... } |
| Return early from a function | val v = value ?: return |
| Crash with a clear message because it is a bug | requireNotNull(value) { "message" } |
| Read a property or call a function and accept a null result | value?.property |
| Say "I promised to set it later" for a property | lateinit var |
The most important design question comes before all of these. Should this value be nullable at all? If a student always has a name, make it String. Only use String? when "missing" is a genuine possibility that your program must handle. Fewer nullable types means less handling code.
Exercise 1: A friendly greeting
Write a function greet(name: String?): String that returns "Hello, <name>!" when a name is given and "Hello, guest!" when the name is null. Call it twice from main, once with "Ada" and once with null, and print both results.
Use the Elvis operator, and write the function with a single expression.
fun greet(name: String?): String = "Hello, ${name ?: "guest"}!"
fun main() {
println(greet("Ada"))
println(greet(null))
}
Expected output:
Hello, Ada!
Hello, guest!
Explanation: Inside ${ } you can write any expression, including one with its own quotes. name ?: "guest" yields "Ada" for the first call and "guest" for the second.
Exercise 2: Add up the good numbers
A form gave you a list of texts typed by users: "12", "x", "7", "3.5", "20". Some are not whole numbers. Write a program that adds up only the valid whole numbers and prints how many entries were skipped.
Hint: toIntOrNull() returns null for bad input, and ?: continue skips to the next loop round.
fun main() {
val inputs = listOf("12", "x", "7", "3.5", "20")
var total = 0
var skipped = 0
for (text in inputs) {
val number = text.toIntOrNull()
if (number == null) {
skipped++
continue
}
total += number
}
println("Total: $total")
println("Skipped: $skipped")
}
Expected output:
Total: 39
Skipped: 2
Explanation: "12", "7" and "20" convert to numbers: 12 + 7 + 20 = 39. "x" and "3.5" return null (a decimal is not a whole number), so two entries are skipped. After the if (number == null) { ... continue } check, number is smart-cast to Int, so total += number compiles. listOf is explained in lesson 14.
Exercise 3: Which contact do we use?
Create a class Student(val name: String, val phone: String?, val email: String?). Write a function contactLine(s: Student): String that returns "<name>: <phone>" if there is a phone, otherwise the email, otherwise "no contact". Test it with three students: one with only a phone, one with only an email, and one with neither.
class Student(val name: String, val phone: String?, val email: String?)
fun contactLine(s: Student): String {
val contact = s.phone ?: s.email ?: "no contact"
return "${s.name}: $contact"
}
fun main() {
println(contactLine(Student("Ada", "0803 111 2222", null)))
println(contactLine(Student("Bola", null, "bola@example.com")))
println(contactLine(Student("Chidi", null, null)))
}
Expected output:
Ada: 0803 111 2222
Bola: bola@example.com
Chidi: no contact
Explanation: Elvis operators chain left to right. s.phone ?: s.email ?: "no contact" tries the phone first, then the email, then the fixed text. Classes are covered in lesson 11.
In the Playground, declare var score: Int? = null. Print score, then print score ?: 0, then assign score = 85 and print both again. Predict the four lines before you run it.
Mistake #1: Making everything nullable "just in case". The wrong code: val name: String? = "Ada" everywhere. The problem: you must now handle null every time you use it, and the code fills with ?. and ?:. The fix: use a non-null type unless "missing" is a real situation. Let the compiler guarantee the value.
Mistake #2: Reaching for !! when the compiler complains. The wrong code: println(name!!.length). The problem: it silences the compiler but moves the crash to runtime. The fix: println(name?.length ?: 0) or an if (name != null) check.
Mistake #3: Forgetting that ?. produces a nullable result. The wrong code:
val name: String? = "Ada"
val len: Int = name?.length
The problem: name?.length is Int?, not Int, so it cannot be stored in an Int. The fix: give a default, val len: Int = name?.length ?: 0, or declare val len: Int? = name?.length.
Mistake #4: Expecting a smart cast on a class var. The wrong code: if (p.bio != null) println(p.bio.length). The problem: bio could change between the check and the use. The fix: copy into a local val first (Example 6) or use p.bio?.length.
Mistake #5: Confusing null with an empty string. The wrong code: if (name == "") { /* handle missing */ } when name can also be null. The fix: use name.isNullOrEmpty() to cover both, or isNullOrBlank() to also cover spaces.
Mistake #6: Using toInt() on user input. The wrong code: val age = text.toInt(). The problem: if the user typed "abc", the program crashes with a NumberFormatException. The fix: val age = text.toIntOrNull() ?: 0.
Mistake #7: Using lateinit and then reading too early. The problem: reading a lateinit property before assignment crashes. The fix: make sure assignment happens first, check with ::name.isInitialized, or use a nullable type if "not set yet" is a normal state.
Answer each question in your head, then reveal the answer.
Question 1. What is the difference between String and String??
String can never hold null, so you can use its members freely. String? may hold either a text or null, so Kotlin forces you to handle the missing case (with ?., ?:, an if check and so on) before using its members.
Question 2. What does this print?
val x: String? = null
println(x?.length ?: -1)
-1. The safe call x?.length gives null because x is null, and the Elvis operator replaces that null with -1.
Question 3. Why is !! risky, and what should you usually write instead?
!! asserts the value is not null and throws a NullPointerException if it is, so the compiler's protection is switched off and the crash can reach users. Prefer ?. with ?:, an if (x != null) check, x?.let { ... }, or requireNotNull(x) { "message" } when null really is a bug and you want a clear error message.
Question 4. Inside if (city != null) { ... }, why can you write city.length without a question mark?
Because of a smart cast. The compiler has proved that city is not null inside that block, so it treats city as a non-null String there. This works for local vals and unchanged local vars, but not for var properties of a class, since those could change at any time.
Question 5. What does "abc".toIntOrNull() return, and why is that better than "abc".toInt() for user input?
It returns null. toInt() would throw a NumberFormatException and crash the program, while toIntOrNull() lets you decide what to do with bad input, for example toIntOrNull() ?: 0.
Go deeper: official resources
- Android Basics with Compose, Unit 2, Pathway 1: Kotlin fundamentals: the official pathway containing the Use nullability in Kotlin codelab.
- Kotlin docs: Null safety: the full reference for
?.,?:,!!, smart casts and safe casts. - Kotlin Playground: run every example in this lesson.
- Kotlin docs: Properties, including late-initialized properties: the rules for
lateinit.
Project goal: Build a small console program that prints a directory of students where some information may be missing, without a single crash and without a single !!.
Requirements:
- Create a class
Studentwith a non-nullname, and nullablephoneandemail, plus a nullableageof typeInt?. - Create a list of at least four students with different combinations of missing data (use
listOf, see lesson 14). - Write a function
contactLine(s: Student): Stringthat returns the phone, else the email, else"no contact". - Write a function
ageGroup(age: Int?): Stringthat returns"unknown"for null,"minor"under 18, and"adult"otherwise. - Loop over the students and print one line each in the format
Name | contact | ageGroup. - After the loop, print how many students have no contact information at all.
- Use at least one
?., one?:, and one?.let.
Stretch goal: Write fun parseAge(text: String): Int? that uses toIntOrNull() and returns null for numbers below 0 or above 120. Build one extra student from the text "abc" and show that the program handles it.
class Student(
val name: String,
val phone: String?,
val email: String?,
val age: Int?
)
fun contactLine(s: Student): String = s.phone ?: s.email ?: "no contact"
fun ageGroup(age: Int?): String {
if (age == null) return "unknown"
return if (age < 18) "minor" else "adult"
}
fun parseAge(text: String): Int? {
val n = text.toIntOrNull() ?: return null
return if (n in 0..120) n else null
}
fun main() {
val students = listOf(
Student("Ada", "0803 111 2222", "ada@example.com", 17),
Student("Bola", null, "bola@example.com", 25),
Student("Chidi", null, null, null),
Student("Dayo", "0809 555 0000", null, parseAge("abc"))
)
var noContact = 0
for (s in students) {
println("${s.name} | ${contactLine(s)} | ${ageGroup(s.age)}")
if (s.phone == null && s.email == null) {
noContact++
}
s.email?.let { println(" (email on file: ${it.length} characters)") }
}
println("Students with no contact: $noContact")
}
Expected output:
Ada | 0803 111 2222 | minor
(email on file: 15 characters)
Bola | bola@example.com | adult
(email on file: 16 characters)
Chidi | no contact | unknown
Dayo | 0809 555 0000 | unknown
Students with no contact: 1
Why it works: Ada has a phone, so the Elvis chain stops there, and age 17 gives "minor". Her email ada@example.com has 15 characters (3 + 1 + 11). Bola has no phone, so the email is used, 25 is an adult, and bola@example.com has 16 characters. Chidi has nothing, so "no contact" and an unknown age; ?.let is skipped because his email is null. Dayo's age comes from parseAge("abc"), which is null, so the group is unknown. Only Chidi lacks both phone and email, so the final count is 1.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 10 project”.
On github.com click New repository, name it android-lesson-10-nullability-in-kotlin, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 10 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What Is null?
- Worked Examples: Nullable Types
- Worked Examples: Checks, Smart Casts and let
- Choosing the Right Tool
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check