1. Diving into null
When you see String?, a beginner’s brain often reads it as: “it can be null, so I should slap on ?. and move on.” But for program design, a more useful reading is this: String? is an author’s promise for the function/field that the absence of a value is a normal, expected outcome. And this is where adult life begins: if null is a normal outcome, its meaning must be as clear as the meaning of true/false or 0/1.
Let’s lock in the idea with a simple example. In Kotlin tutorials, people often show a parser: if a string is not a number, return null. That’s a very honest contract: “couldn’t parse it — here’s the signal, no exceptions and no mystery.”
fun parsePort(raw: String): Int? {
val trimmed = raw.trim()
return trimmed.toIntOrNull()
}
fun main() {
println(parsePort("8080")) // 8080
println(parsePort("oops")) // null
}
Here null means a very specific thing: “the string does not contain a valid integer.” This is not a system error, not a bug, not “something went wrong” — it’s a routine situation at the input boundary.
Now imagine you did this:
fun loadConfigText(): String? {
// ... reading something
return null
}
And that’s it. But what does null mean? Config not found? No permissions? Empty file? Did you forget to finish the function? This is already a bad contract: it forces the caller to guess.
The key criterion: if you put a ?, you must be able to answer the question “why might the value be absent?” in a way that doesn’t look like “because I’m lazy.”
2. Where null is usually justified: program boundaries
Usually null is an excellent tool at system boundaries — where you interact with an unpredictable world: user input, command-line arguments, network data, file reads, key lookups (when an element may genuinely be absent). At the boundary, the world is allowed to bring you emptiness, a weird string, or “I didn’t enter anything at all.” And that’s where null fits.
In Kotlin, even basic collection operations hint at this approach: for example, reading by key from a Map returns V?, because the key may not exist, and that’s a normal scenario. In that spot, null reads as “not found,” not “broken.”
Let’s take an example closer to our practical console application (let’s call it ExpenseTracker: a small CLI expense tracker). Suppose we have a list of expenses and we search for one by id. It’s perfectly normal to return Expense?: the expense may have been deleted, the id may be wrong, or the user may have made a mistake.
data class Expense(
val id: Int,
val title: String,
val amount: Int
)
fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
return expenses.find { it.id == id }
}
fun main() {
val expenses = listOf(
Expense(id = 1, title = "Coffee", amount = 300),
Expense(id = 2, title = "Book", amount = 1200),
)
val e = findExpenseById(expenses, 2)
println(e?.title) // Book
}
Notice: the contract reads perfectly. Expense? here means “might not be found.” And that’s not an “error”: it’s part of the domain.
3. Where null starts to get in the way: inside the model and business logic
Inside the program (after the data has already been recognized and validated), null often turns into a “film of chaos.” The reason is simple: null starts to leak through layers of code. One nullable field pulls in a second, then a third; then you get ?. in formatting, ?: in calculations, if (x != null) in three different places, and at the end — the philosophical question “what even counts as a valid object?”
Let’s imagine a bad version of our expense model:
data class ExpenseDraft(
val title: String?,
val amount: Int?
)
fun main() {
val draft = ExpenseDraft(title = null, amount = null)
println("title=${draft.title}, amount=${draft.amount}") // title=null, amount=null
}
From the outside it looks convenient: “you can create at least something.” But then you’ll want to:
- show the expense to the user,
- compute totals,
- sort,
- group.
And everywhere you’ll be thinking: “could this be null?” It’s like carrying an umbrella at home in the kitchen “just in case”: technically you can, but cooking becomes awkward.
An important idea of today’s lecture: nullable fields inside a “normal” model often mean you mixed different data stages. There’s “raw input” and there’s a “valid entity.” If you mix them in one type, null will spread quickly.
4. Two meanings of null: “no data” and “error”
This part is especially important, because this is where the nastiest bugs begin — not the ones that crash immediately, but the ones that quietly corrupt logic.
null is usually used in two different situations:
| Situation | What it means conceptually | Is returning null OK? | Example |
|---|---|---|---|
| Missing value | The value may not exist, and that is expected | Yes | “not found by id”, “empty input”, “failed to parse a number” |
| Error/impossibility | We wanted a value, but did not get it due to a problem | Risky | “failed to read file”, “network error”, “logic broke” |
The problem is that both situations technically look the same: “returned null.” But conceptually they differ as much as “the store is out of bread” and “the store burned down.”
Let’s intentionally make a bad example and see why it’s bad:
fun loadUserName(userId: Int): String? {
// Imagine there could be a database error here,
// but we just returned null
return null
}
The calling code can no longer tell:
- the user with that id really does not exist (fine),
- the user exists but the database went down (not fine),
- the developer forgot to implement the function (also not fine).
And then, typically, someone writes:
val name = loadUserName(10) ?: "UNKNOWN"
println("Hello, $name") // Hello, UNKNOWN
And there it is — a “silent bug”: the program kept running, but the meaning of the data was corrupted. That’s more dangerous than a crash, because a crash at least honestly screams: “I’m broken.” But "UNKNOWN" can end up in a report, an export, final totals, and further down the chain.
Today’s goal is to learn to spot such places and not use null as a universal “plug for everything.”
5. Practice: how to keep null under control
Criterion: “Can I explain null in one sentence?”
To keep design from turning into philosophy, a very practical test helps.
If a function/field has type T?, try to add a one-line comment to it without feeling awkward. Not a paragraph, not “well, basically…”, but a single sentence.
For example, good options:
- findExpenseById(...) : Expense? — “returns the expense or null if not found”.
- parseAmount(...) : Int? — “returns the amount or null if the string is not an integer”.
- normalizeTitle(...) : String? — “returns the trimmed string or null if it’s empty after trim”.
And here are bad options that should raise a red flag:
- “returns null if something went wrong”
- “returns null in case of an error”
- “might return null, I’m not sure”
If you catch yourself using such wording, you are almost certainly trying to hide in null something that conceptually is an error or a separate state.
ExpenseTracker: making contracts readable
For now, we will not add “advanced” constructs and new result types (that’s a separate topic for later lectures). Instead, we’ll practice choosing T vs T? correctly and making the contract understandable.
Normalizing user input: String? is appropriate
A user can enter an empty string, a string of spaces, or just “hit Enter and say nothing.” This is a classic boundary. Here null is honest.
fun normalizeNonEmptyText(raw: String): String? {
val s = raw.trim()
return if (s.isEmpty()) null else s
}
fun main() {
println(normalizeNonEmptyText(" hello ")) // hello
println(normalizeNonEmptyText(" ")) // null
}
null here means “after normalization, there is no text.” This is missing data, not an error.
Parsing a number: Int? is appropriate
Kotlin nudges you directly toward this style: toIntOrNull() returns nullable because parsing is not always possible. This is an example of an “expected failure,” and it’s good.
fun parseAmount(raw: String): Int? {
val s = raw.trim()
return s.toIntOrNull()
}
fun main() {
println(parseAmount("1200")) // 1200
println(parseAmount("12o0")) // null
}
Collection lookup: Expense? is appropriate
This is “not found.” A normal story.
fun removeExpenseById(expenses: MutableList<Expense>, id: Int): Boolean {
val found = expenses.find { it.id == id } ?: return false
expenses.remove(found)
return true
}
fun main() {
val expenses = mutableListOf(
Expense(1, "Coffee", 300),
Expense(2, "Book", 1200),
)
println(removeExpenseById(expenses, 2)) // true
println(removeExpenseById(expenses, 99)) // false
}
Here I did this on purpose so that Expense? does not leak outward. The “remove” function uses a nullable lookup internally, but returns a simple and clear result outward: Boolean (“removed / not removed”). This is also part of the culture: keep nullable close to where it appeared, and return a more convenient contract outward if you can.
Anti-pattern: making everything nullable “so the compiler complains less”
It’s very tempting: the compiler complains that you’re accessing a nullable — and you go, “fine, I’ll make everything nullable and let the world burn.” In the short run it feels easier. In the long run it turns code into a quest: “find the place where someone forgot ?..”
To feel the difference, compare two formatting approaches:
fun formatExpenseBad(title: String?, amount: Int?): String {
val safeTitle = title ?: "UNKNOWN"
val safeAmount = amount ?: 0
return "$safeTitle: $safeAmount"
}
fun main() {
println(formatExpenseBad(null, null)) // UNKNOWN: 0
}
On the one hand, the program doesn’t crash. On the other hand, the data has already been substituted. 0 is not “no amount”; it is “amount zero,” and your report may now become false.
If in your app 0 is a valid expense (e.g., promo code, discount, refund), you can no longer distinguish “empty” from “zero.” And that’s exactly why null is a contract of meaning, not “just absence of a value.”
Sentinel values instead of null: why they are dangerous
Sometimes developers say: “I don’t like null, so let’s use -1 instead.” Such a value is called a sentinel: a special number/string that means “there is nothing.”
This can work, but there’s a risk: you add a hidden agreement into the code. If you forget to check -1 in one place, the logic breaks, but the compiler won’t help you, because the type is still Int.
Here’s a small example where the bug looks “respectable,” but the meaning is already wrong:
fun parseAmountOrMinusOne(raw: String): Int {
return raw.trim().toIntOrNull() ?: -1
}
fun main() {
val amount = parseAmountOrMinusOne("oops")
println(amount + 100) // 99 (and this looks as if everything is fine)
}
That’s why Kotlin promotes nullable: Int? forces you to handle the “no value” case explicitly, while -1 lets you forget by accident.
This does not mean sentinels are always evil. It means that if you use them, you must make it a contract, not a “silent trick.”
One processing “gateway” instead of smearing null everywhere
In good code, you can often see a style: null lives in one place, near input or lookup, and then either turns into a concrete action or gets cut off. In bad code, null seeps into all levels: domain model, reports, formatting, sorting.
Schematically, it looks like this:
flowchart LR
A["External world (input, search, data)"] -->|may be null| B["Gateway: normalization/validation"]
B -->|not-null| C["Internal logic (model, calculations)"]
B -->|no value| D["Understandable response (message/branch/output)"]
The idea is to have fewer places in your code where you reinvent “what do we do with null?” each time. Let it be solved in one clear place.
For example, when parsing a command:
data class AddExpenseCommand(val title: String, val amount: Int)
fun parseAddCommand(line: String): AddExpenseCommand? {
val parts = line.trim().split(" ")
if (parts.size < 3) return null
val title = parts[1].trim().ifEmpty { return null }
val amount = parts[2].trim().toIntOrNull() ?: return null
return AddExpenseCommand(title = title, amount = amount)
}
fun main() {
println(parseAddCommand("add coffee 300")) // AddExpenseCommand(title=coffee, amount=300)
println(parseAddCommand("add coffee oops")) // null
}
Here null means a very specific thing: “the command is invalid.” And it’s important that null does not leak further. We did not make AddExpenseCommand(title: String?, amount: Int?). We either return a normal command, or null as a “not recognized” signal.
Nullable receiver in extension functions: localizing checks
Sometimes you want a small utility that can work even if the value is null. Kotlin allows writing extension functions on a nullable type (Any?, String?, etc.). This is useful when you want to hide a repeated check in one place and not write it at every call site. Kotlin’s official documentation shows this too: an extension on Any? can return the string "null" instead of crashing, and inside the body after checking this == null, the compiler already knows that this is not null.
For our app, you can make something very simple — safe text rendering:
fun String?.toUiText(): String {
return this?.trim()?.takeIf { it.isNotEmpty() } ?: "<empty>"
}
fun main() {
val a: String? = " hello "
val b: String? = " "
val c: String? = null
println(a.toUiText()) // hello
println(b.toUiText()) // <empty>
println(c.toUiText()) // <empty>
}
Notice the fine line: this is appropriate in UI/output, but not always appropriate in calculations and data storage. Inside the model, "<empty>" would be a weird “sentinel,” but for printing to the console it’s a normal, deliberate choice.
6. Typical mistakes when working with null
Mistake #1: making model fields nullable “just in case.”
Most often, this comes from the desire to “create the object faster.” As a result, the object becomes easy to create but hard to use: everywhere you have to remember ?. and invent what to do with absence. Usually, this is a sign that you mixed “raw input” and a “valid entity” in one type.
Mistake #2: returning null both for “not found” and for “error.”
Such a contract forces the calling code to guess the reason, which means it will either start “silently fixing” everything via ?:, or start crashing via !!, or just print "UNKNOWN" and corrupt data. If you catch yourself saying “we’ll return null if something went wrong,” it is almost always a signal that the contract is vague.
Mistake #3: “fixing” the design of null contracts with the !! operator.
!! is sometimes needed, but in real projects it often turns into “I’m tired of thinking.” The problem is that you didn’t solve the meaning issue: you just moved the crash to runtime. In the “nullable as a contract” framing, it looks like this: “the contract says the value may be absent, but I pretend it exists.”
Mistake #4: replacing null with sentinels (0, -1, "UNKNOWN") without a strict agreement.
If you substitute a “default” value, you change the meaning of the data. Sometimes that’s appropriate (especially for UI), but if it’s done without a single policy and without understanding the domain, you get silent logical errors: totals are “almost correct,” reports look “almost normal,” and then the user asks why they have an expense "UNKNOWN: 0".
Mistake #5: smearing null across the pipeline instead of having one processing gateway.
When null travels through 5–10 functions, you lose control: where exactly did it appear, and what does it mean? It is much more robust when null lives near its source (input/lookup) and quickly turns into a clear decision: “ask again,” “not found,” “command is invalid.”