1. Introduction
When you’re just starting out, it feels very tempting to make a model “for every possible case”: name: String?, category: String?, amount: Int? — and we’ll figure it out later. At that moment you’re literally buying yourself freedom… but on credit, at a huge interest rate. Because every next piece of code will be forced to “pay” with null checks: when printing, sorting, calculating totals, formatting messages, and even in the simplest conditions. Kotlin, of course, warns you honestly: nullable types require explicit handling.
How null “spreads” through the program
Imagine that in our console app (we’ll continue a practical example — a hypothetical expense tracker) you store expenses like this:
data class BadExpense(
val amountCents: Int?,
val category: String?,
val note: String?
)
At first it seems: “convenient, you can create an object even from messy input.” But after a couple of functions it starts:
- “how do you sum Int??”
- “what do we do if category == null?”
- “can we even print note?”
- “is sorting by category even possible?”
And you start turning the code into a forest of ?:, ?.let {}, if (x == null) return ....
Main idea of the lecture
Nullable on the outside, strict types on the inside.
That is, null is acceptable at the boundary (input, external data), but after normalization/validation we must obtain internal objects without nullable fields.
2. Where null is acceptable and where it isn’t
To design without nullable fields, you first need to agree on where null is even allowed. Now we’ll do it as pragmatically as possible: not “philosophically,” but “so the code doesn’t hurt.”
In real applications, the null boundary almost always runs where data comes from the outside: the user typed a string, an HTTP request came in, you read a file, you got data from a database. Today we’re not touching files/network/DB, but the principle is the same: external input is unpredictable; the internal model must be predictable.
Here’s a simple diagram that’s useful to keep in mind:
flowchart LR
A["Raw input (String? / String)"] --> B["Normalization (trim, lowercase, split)"]
B --> C["Parsing + validation (toIntOrNull, rules)"]
C --> D["Internal model (no null)"]
A quick table: “what’s allowed, what’s not”
| Program layer | Which types are allowed there | What we consider the norm |
|---|---|---|
| Input (CLI) | String, sometimes String? | Input can be empty/messy |
| Normalization | String? for a short section | “Brought into shape,” but we’re not confident yet |
| Parsing/validation | Result<T>, sealed result, sometimes T? | Errors must be explicit |
| Internal model (domain) | Only non-null (String, Int, enum, …) | If an object exists, it’s valid |
3. Internal model: the object is always valid
Now we’ll design a strict expense model for our console tracker. The idea is simple: if the list contains an Expense, then it has an amount, a category, and a note (or at least an empty note — but that decision must be intentional).
We’ll use a data class, because it’s a convenient form for a data model: Kotlin automatically generates useful methods like toString(), equals(), copy(), and destructuring.
Category as an enum, not a String
If a category is a string, you’ll very quickly get "Food", "food", "FOOD", "meal", "Meal", "MeAl" and a mysterious "Foood" (it’s not a new category, it’s a typo at 3 a.m.).
So in a strict model we’ll make categories an enum:
enum class Category {
FOOD, TRANSPORT, FUN, OTHER
}
Strict Expense without nullable fields
Let’s create the expense model. We’ll store the amount in cents (an integer), because Double for money is its own horror subgenre.
data class Expense(
val id: Int,
val amountCents: Int,
val category: Category,
val note: String
) {
init {
require(id > 0) { "id must be positive" }
require(amountCents > 0) { "amountCents must be positive" }
require(note.isNotBlank()) { "note must not be blank" }
}
}
Here we use require(...) as fail-fast: if someone inside the program tries to create an “invalid” Expense, it’s better to fail immediately with a clear message than to carry the bug further.
Important: require is not “user input validation” (because user input is something we want to handle gently). It protects the model’s invariants: “this object must not exist inside the program.”
4. Raw input: a separate type with null
Now let’s do what we came for: introduce a separate type for raw input. It can be messy, empty, unparsed, partial. And that’s fine, because it’s not a model yet; it’s a draft.
A trick (very useful in real projects): don’t try to create an Expense from input right away. Instead, create a Raw… or …Input, and then convert it into a strict model with a separate function.
RawExpenseInput: data “as is”
data class RawExpenseInput(
val amount: String?,
val category: String?,
val note: String?
)
Looks too simple? Great. Our goal is not a “smart class,” but an honest box of what came from the outside.
Normalization: bring strings into a reasonable form
Normalization is “wash the data and comb it,” but not “trust it” yet. For example, if a string consists of spaces, we treat it as “no value.”
fun normalizeText(raw: String?): String? {
val s = raw?.trim()
return s?.takeIf { it.isNotEmpty() }
}
Note: normalizeText returns String?. That’s fine: it still works at the boundary. But next we’ll turn that String? into an explicit result.
5. Parsing: a sealed result instead of null
Now the key moment: how do we return the parsing/validation result so that:
- there is no null inside the model,
- the calling code understands what went wrong,
- we don’t “silence” errors with println somewhere deep inside.
For a learning project, it’s very convenient to make a sealed class result: either Ok or Error (you can name it Invalid, Missing, and so on).
Parsing result: Ok/Error
sealed class ParseExpenseResult {
data class Ok(val draft: ExpenseDraft) : ParseExpenseResult()
data class Error(val message: String) : ParseExpenseResult()
}
And what is ExpenseDraft? It’s an “internally valid expense draft,” but without an id (id is usually assigned when adding to a list/repository).
data class ExpenseDraft(
val amountCents: Int,
val category: Category,
val note: String
)
ExpenseDraft is already strict: no nullable fields. That’s very convenient — from here on the logic works only with non-null data.
Parsing the amount: from a string to an Int, in cents
For simplicity, we’ll enter the amount in cents as an integer, for example 199 = 199 cents. (Yes, it’s not super user-friendly, but for learning it’s great: we avoid floating-point numbers.)
fun parseAmountCents(text: String): Int? {
val x = text.toIntOrNull()
return x?.takeIf { it > 0 }
}
Here, Int? is acceptable, because this is a local boundary function: “failed to parse” or “didn’t pass the rule.”
Parsing the category: from a string to Category
fun parseCategory(text: String): Category? {
return when (text.trim().lowercase()) {
"food" -> Category.FOOD
"transport" -> Category.TRANSPORT
"fun" -> Category.FUN
"other" -> Category.OTHER
else -> null
}
}
null here means “category not recognized.” That’s a normal boundary contract.
Building ExpenseDraft from RawExpenseInput
Now we combine everything. This is the “gateway” that doesn’t let null inside.
fun parseExpenseDraft(raw: RawExpenseInput): ParseExpenseResult {
val amountText = normalizeText(raw.amount)
?: return ParseExpenseResult.Error("Amount is missing")
val amount = parseAmountCents(amountText)
?: return ParseExpenseResult.Error("Amount must be a positive integer")
val categoryText = normalizeText(raw.category)
?: return ParseExpenseResult.Error("Category is missing")
val category = parseCategory(categoryText)
?: return ParseExpenseResult.Error("Unknown category: $categoryText")
val note = normalizeText(raw.note)
?: return ParseExpenseResult.Error("Note is missing")
return ParseExpenseResult.Ok(
ExpenseDraft(amountCents = amount, category = category, note = note)
)
}
Notice the style: lots of early returns. This keeps the code “flat”: no huge nested ifs, and each error is returned with a concrete reason. The Kotlin approach “nullable values → check → early exit” is absolutely normal practice.
6. CLI scenario: adding an expense without null
Now let’s connect everything into a mini scenario. We have a list of expenses, an add command, and the user enters data. We’ll intentionally keep the example compact so you can hold it all in your head.
Simple id generation
fun nextId(expenses: List<Expense>): Int {
val maxId = expenses.maxOfOrNull { it.id } ?: 0
return maxId + 1
}
maxOfOrNull returns nullable because the list can be empty — this is a normal “collection nullable boundary.” We carefully turn it into 0 via Elvis ?:, and then live again with Int, not Int?.
Turning ExpenseDraft into Expense
Here, Expense can already apply its require(...) in init. But in a normal flow it won’t get to that: we already validated everything during parsing.
fun createExpense(expenses: List<Expense>, draft: ExpenseDraft): Expense {
return Expense(
id = nextId(expenses),
amountCents = draft.amountCents,
category = draft.category,
note = draft.note
)
}
Parsing the add command from a string
Suppose the command format is:
add <amountCents> <category> <note...>
For example:
add 199 food coffee
add 500 transport taxi to airport
Let’s make a simple parser (without future topics like Regex validation from day 27). Let note be everything after the first two tokens.
fun rawInputFromAddCommand(line: String): RawExpenseInput {
val parts = line.trim().split(" ")
val amount = parts.getOrNull(1)
val category = parts.getOrNull(2)
val note = parts.drop(3).joinToString(" ").takeIf { it.isNotBlank() }
return RawExpenseInput(amount = amount, category = category, note = note)
}
getOrNull is a very honest way to say: “the element may be missing.” Again: nullable lives at the boundary, and then we’ll “squeeze it down” into ParseExpenseResult.
add command: full scenario
fun handleAdd(expenses: MutableList<Expense>, line: String) {
val raw = rawInputFromAddCommand(line)
when (val parsed = parseExpenseDraft(raw)) {
is ParseExpenseResult.Error -> println("ERROR: ${parsed.message}")
is ParseExpenseResult.Ok -> {
val expense = createExpense(expenses, parsed.draft)
expenses.add(expense)
println("Added: $expense") // Added: Expense(id=1, amountCents=199, category=FOOD, note=coffee)
}
}
}
Notice: inside handleAdd there are no String?, Int?, and certainly no Expense?. We work either with Error(message) or with Ok(draft) — and that greatly reduces cognitive load.
An empty string instead of null: why sentinels are dangerous
Sometimes you want to say: “What if instead of note: String? we store note: String, and if the user entered nothing, we put an empty string.” Formally, this removes nullable. But a new problem appears: the empty string starts to mean “no note,” and you must remember that everywhere.
Sentinel values (like "", 0, "UNKNOWN") are sometimes appropriate, but only if you’re ready to lock them in as a contract and handle them in one or two places, instead of smearing them across the project.
In our example we chose a different contract: note is required and cannot be empty; this is checked at the boundary, and inside the model it’s protected by require(note.isNotBlank()).
If you truly need “note is optional,” it’s better not to implement that via a sentinel, but as a separate result variant or a separate field at the raw-input layer. For example, RawExpenseInput.note may be null, but when assembling ExpenseDraft you can decide: either it’s an error (as we did), or you substitute a meaningful default.
7. Common mistakes
Mistake #1: making nullable fields in the domain model “just in case.”
This almost always leads to null participating in every operation: calculations, printing, filtering, sorting. In the end, you’re not writing an “expense tracker,” you’re writing “tracking where else null could be.” It’s much more beneficial to accept that input data can be messy, not your internal model: introduce Raw…, validate, and only then create a strict object.
Mistake #2: returning Expense? from parsing and printing the error reason via println inside.
This gives the caller “well, either there’s an object or there isn’t,” but doesn’t explain why there isn’t. And the reason flies into the console somewhere deep, mixing with other messages. A sealed class result (Ok/Error) or Result<T> solves this more cleanly: the reason becomes part of the value, not a side effect.
Mistake #3: mixing “input error” and “internal failure” using the same style.
A user can enter "abc" instead of a number — that’s an expected situation, and it should be returned as Error("Amount must be..."). But trying to create Expense(amountCents = -10) from already validated data is more likely a program bug, and require(...) in the model (fail-fast) is appropriate there.
Mistake #4: “we already checked,” but still using !!.
!! usually appears when you haven’t agreed on where exactly the null boundary is. In our approach, the boundary is in parsing: while we’re in Raw… and normalization, nullable is allowed, but after ParseExpenseResult.Ok there should be no reason for !! inside. If your hand reaches for !!, it’s a good signal: either you missed validation, or you mixed layers.
Mistake #5: making nullable fields in the Ok branch of a sealed result (“Ok, but the value may be null”).
This is a sneaky way to return null “in a mask.” You end up with Ok(value: T?), and you drag uncertainty inside again. If the result is Ok, it must contain a strict value without null, otherwise the whole idea of a strict model falls apart.
GO TO FULL VERSION