1. Why you need lateinit
Sometimes you need to create an object right away, but the data for it will only appear later—according to the program flow. For example, you are writing a console app: the user starts the program, it creates the application “core”, and only then asks questions: name, currency, operating mode, etc. From the code’s point of view, you get a situation where the object already exists, but some important fields will appear only after a dialog with the user. And you want those fields to be non-null, because “if the currency is not set, the app simply makes no sense”.
A typical beginner’s dilemma looks like this: either make the field nullable (String?) and then write checks everywhere, or give some “temporary” value like "UNKNOWN" and then hope you won’t forget to replace it with the real one. And this is where Kotlin offers a tool that sounds like a magic spell from Hogwarts: lateinit.
The problem: the object already exists, but the data is not there yet
Imagine you bought a kettle (the object is created), but you haven’t poured water in yet (the property is not ready). A kettle without water is fine, but you must not turn it on. And good API design should prevent “turning on an empty kettle”.
lateinit as a “will be initialized later” contract
It’s important to catch the main idea before your hand reaches to slap lateinit onto every class “just in case”. lateinit is not a way to “trick Kotlin”; it is a way to explicitly record a contract: “this property will definitely be set, but not at the moment the object is created”. That is, you don’t make the field nullable; you say: “it will be non-null, just a bit later”.
When you use lateinit, you declare a property var without initialization, but you promise that it will be set later (usually by a separate method, or by external logic that is guaranteed to run). Kotlin allows this, but the responsibility for the call order is shifted to you.
class Config {
lateinit var apiKey: String
}
Here apiKey is not nullable (String, not String?), but when you create Config() it is not set yet. Later you will do config.apiKey = "...".
Limitations of lateinit
There are several limitations related to lateinit, and they are not compiler “spite”; they exist because otherwise the behavior would become too “magical” and dangerous.
lateinit can be used only if:
| What we want | Can we use lateinit? | Why |
|---|---|---|
|
Yes | Non-null reference type; can be assigned later |
|
Yes | Also a non-null reference type |
|
No | val cannot be assigned later—it must be set immediately |
|
No | For primitives (Int, Double, Boolean) lateinit does not work |
|
No (and you don’t need it) | If null is allowed, it is simpler and more honest to use a nullable type |
The fact that lateinit does not work for Int often surprises people. But if you think about it: Kotlin cannot “store the absence of a value” inside an Int without some special marker. References have a natural “no value” marker—null—but for Int there is no such “empty state” (at least at the level of the Kotlin contract).
Why lateinit almost always needs an explicit type
You won’t have a value on the right-hand side, so Kotlin won’t be able to infer the type from initialization. Therefore, you need to specify the type explicitly—this is the same principle we already saw for variables without an initial value.
Compare:
val x = 10 // the type will be inferred
val y: Int // without initialization, the type is required
With lateinit, it’s the same idea, just inside a class:
class Session {
lateinit var currency: String
}
What happens if you access lateinit too early
lateinit is not a “soft” construct. It is very honest: if you break the contract (read the property before assignment), the program crashes with a runtime error. And that is actually good news: the error happens immediately instead of turning into silent data corruption.
Let’s see what it looks like in a mini example. Here I do it deliberately wrong so you can see the symptom.
class Profile {
lateinit var userName: String
}
fun main() {
val p = Profile()
// We forgot p.userName = "..."
println(p.userName) // Boom
}
In real life this usually shows up as an exception like “property ... has not been initialized”. If you start “fixing” it with try/catch, you’ll only mask the bug: the application will keep running in an incorrect state. So the normal strategy is not to “catch the crash”, but to design the API so that the crash cannot happen.
2. Safe API design with lateinit
When lateinit appears, a second question almost always follows: “How do I make sure no one (including me a week later) can call methods in the wrong order?” That is API design. We want using the object to look like a clear scenario: prepare first, then work. And if the scenario is broken—we get fail-fast with a good message.
Here require(...) and check(...) help a lot: the first is about input validity, the second is about object state validity.
Pattern: init(...) + check(...)
The essence of the pattern is simple: you keep lateinit inside, and expose a method init(...) that moves the object into a “ready” state. All methods that require readiness start with an internal check(...).
class AnalyticsClient {
private var ready: Boolean = false
lateinit var apiKey: String
private set
fun init(key: String) {
require(key.isNotBlank()) { "apiKey must not be blank" }
apiKey = key
ready = true
}
fun send(event: String) {
check(ready) { "AnalyticsClient is not initialized. Call init(...) first." }
println("Send '$event' with apiKey=$apiKey")
}
}
Notice: we don’t allow assigning apiKey from the outside (it has a private set), initialization goes through init(...). And check(...) is an explicit guard at the door that won’t let you into methods too early.
Why it’s usually better to hide lateinit inside the class
A very common beginner problem: make a public lateinit var and then assign it from anywhere. It is convenient for exactly one minute—while the project is small. Then you accidentally assign something wrong, or forget to assign, or assign twice—and you start a fun quest: “why is it crashing”.
Try to think like this: lateinit is an internal detail, not a “button for everyone”. So most of the time it should be private or at least have a private set, and on the outside there should be methods that guarantee a correct scenario.
require(...) and check(...): who is responsible for what
It’s easy to mix these up, so let’s put it in plain language.
- require(...) is used when the user (or calling code) passed you an invalid argument: an empty string, a negative number, an unsupported format. This is an “input error”.
- check(...) is used when the arguments are fine, but the object is currently in an inappropriate state. For example, you didn’t call init(...) but you are trying to send(...). This is a “scenario/logic error”.
And yes, these checks are not “being mean to the user”; they are a way to make the code predictable. In the end, the computer will break everything anyway—you just get to choose where exactly.
3. Practical example: BudgetBuddy console expense tracker
So that lateinit doesn’t remain an “abstract thing from a textbook”, let’s plug it into a small piece of a console application. Imagine we are building a simple expense helper: the user starts the program, enters a name and currency, and then adds expenses. Name and currency must be required, but they appear only after startup and a dialog.
We’ll make a BudgetBuddySession class: it is created immediately, but becomes usable only after init(...).
Expense expense model
Let’s start with a very simple expense model. We need it so there is something to store in a list and print. No tricks here: data and a bit of convenience for output.
data class Expense(
val title: String,
val amountCents: Int
) {
init {
require(title.isNotBlank()) { "title must not be blank" }
require(amountCents > 0) { "amountCents must be > 0" }
}
}
require(...) here is about input validity when creating the object: an expense with an empty title and amount 0 is suspicious, like a “100% discount” in a store.
BudgetBuddySession: lateinit for data that appears after startup
Now the “session” itself. It will have the user name, the currency, and a list of expenses. Name and currency are required, but set later, so we make them lateinit. At the same time, we block external writes.
class BudgetBuddySession {
private var ready: Boolean = false
lateinit var userName: String
private set
lateinit var currency: String
private set
private val expenses = mutableListOf<Expense>()
fun init(userName: String, currency: String) {
require(userName.isNotBlank()) { "userName must not be blank" }
require(currency.isNotBlank()) { "currency must not be blank" }
this.userName = userName.trim()
this.currency = currency.trim().uppercase()
ready = true
}
}
Note: we use trim() and uppercase() right inside init(...) so that the state inside the object is normalized. This reduces the chance that later you will compare "usd" and "USD" and wonder why they are not equal.
Methods that require readiness
The next step is to give the session operations: add an expense and print the list. And here is an important technique: don’t smear checks across the entire class; instead, make a small private method ensureReady() and call it where needed.
class BudgetBuddySession {
private var ready: Boolean = false
lateinit var userName: String
private set
lateinit var currency: String
private set
private val expenses = mutableListOf<Expense>()
fun init(userName: String, currency: String) {
require(userName.isNotBlank()) { "userName must not be blank" }
require(currency.isNotBlank()) { "currency must not be blank" }
this.userName = userName.trim()
this.currency = currency.trim().uppercase()
ready = true
}
private fun ensureReady() {
check(ready) { "Session is not initialized. Call init(...) first." }
}
fun addExpense(title: String, amountCents: Int) {
ensureReady()
expenses.add(Expense(title, amountCents))
}
fun printAll() {
ensureReady()
println("Expenses of $userName ($currency):")
expenses.forEach { println("- ${it.title}: ${it.amountCents} cents") }
}
}
Here check(...) is exactly a state check. If you forgot to call init(...), the object is still “not ready”, and the methods honestly tell you so.
Usage in main
Now let’s assemble a small scenario. We create a session, ask the user for name and currency, call init(...), and then use the methods.
fun main() {
val session = BudgetBuddySession()
print("Your name: ")
val name = readln().trim()
print("Currency (e.g., USD): ")
val currency = readln().trim()
session.init(name, currency)
session.addExpense("Coffee", 450)
session.addExpense("Sandwich", 899)
session.printAll()
// Expenses of Alice (USD):
// - Coffee: 450 cents
// - Sandwich: 899 cents
}
From the outside, this looks like a normal contract: initialization first, then work. We don’t allow accidentally doing session.currency = "LOL" in the middle of the program—the property has a private set.
4. When lateinit is appropriate
There is a dangerous moment: once you learn lateinit, your brain starts seeing it as a universal key for every door. But lateinit is not a universal key; it’s more like a side entrance you use when the main entrance is objectively closed.
The main rule sounds like this: if a value is required and known at creation time, pass it via the constructor. If a value can be absent by meaning, use a nullable type. If the value is required but appears later due to the scenario, then lateinit is a candidate.
A small “navigator” table:
| Situation | Better like this | Why |
|---|---|---|
| The value is required and known immediately | constructor (val x: ...) | The object is valid immediately |
| The value can be absent by meaning | T? | “no value” is part of the contract |
| The value is required, but will arrive later | lateinit var + explicit init(...) | Two-stage readiness, but without null |
| The value is computed on first use | lazy (in the next lecture) | A different mechanism: computation + caching |
Note that I said about lazy exactly as much as needed so you don’t confuse it with lateinit: it is not “assign later”, but “compute later”. We’ll cover it in detail in the next lecture.
5. Common mistakes when working with lateinit
Mistake #1: using lateinit “so you don’t have to think”.
The most common reason lateinit appears in the wrong places is the desire to avoid choosing between a constructor and nullable. But lateinit does not remove the need to think; it simply moves responsibility to the call order. If a value must always be known when the object is created, it is much cleaner to pass it in the primary constructor and not give the object a “teenage period” where it exists but still doesn’t understand who it is in life.
Mistake #2: making a public lateinit var and allowing assignment from anywhere.
This makes you lose control over state: someone assigned it, someone overwrote it, someone forgot, someone assigned an empty string. lateinit should almost always be hidden: private or at least with a private set, and the outside world should communicate with the object via methods like init(...), where you can normalize and validate the data.
Mistake #3: checking readiness “wherever” instead of having one explicit contract.
Sometimes code turns into a patchwork quilt: one method has the check, another forgets it, a third uses a different check. As a result, the object behaves unpredictably. It’s much more stable to keep a single control point: either a private ensureReady() with check(...), or a strict rule that public methods are available only after init(...).
Mistake #4: trying to “fix” the problem with try/catch and continuing to work.
If you read a lateinit property before initialization, it is not an “expected input error”; it’s a broken scenario. Catching the exception and continuing almost always means “we will now proceed in an incorrect state and break something else, just somewhere else”. In such cases, fail-fast is more correct: check(...) with a clear message.
Mistake #5: mixing up require(...) and check(...) and getting weird messages.
When the user entered an empty name, that is an argument, and require(...) is more appropriate. When you forgot to call init(...) and went into addExpense(...), that is state, and check(...) is appropriate. If you mix them up, error messages will be misleading: you will “scold the user” for the fact that it was you who forgot the initialization step (or vice versa).