A non-null Kotlin property is null when the base class init calls an overridable function. The subclass overrides that function and reads its own properties. Kotlin runs the base class initialization first. So at that moment, none of the subclass’s fields have been assigned yet. They still hold the JVM default. That is null for objects and 0 for numbers. That includes private val constructor parameters. The type says String. The field holds null. A second cause keeps the bug quiet. The compiler rejects a direct read of an unassigned property in an initializer. It doesn’t follow that read through a function call. It also emits no null check when a class reads its own field. So the null travels until something dereferences it. The fix is a rule for the base class. Its constructor must never call a member a subclass can override. Start the work from the subclass’s own init, after its properties, or from an explicit lifecycle call.
A BaseViewModel that loads data from init crashes its subclass
The running example is a pattern that shows up in Android codebases and in bug-squash snippets. A BaseViewModel wants every screen to load on creation, so it calls an abstract hook from init. ProfileViewModel implements the hook with its repository and a user ID. To compile it outside Android, ViewModel here is a plain stand-in class. Nothing in the bug depends on androidx.lifecycle.
open class ViewModel // stand-in for androidx.lifecycle.ViewModel
interface ProfileRepository { fun load(userId: String): String }
class FakeRepo : ProfileRepository { override fun load(userId: String) = "Profile($userId)" }
abstract class BaseViewModel : ViewModel() {
init {
loadInitialData()
}
protected abstract fun loadInitialData()
}
class ProfileViewModel(
private val repository: ProfileRepository,
) : BaseViewModel() {
private val userId = "u-42"
private val history = mutableListOf<String>()
override fun loadInitialData() {
println("userId=$userId history=$history repository=$repository")
history += repository.load(userId)
}
}
fun main() {
ProfileViewModel(FakeRepo())
}
Compiled with kotlinc 2.4.20 and run on the JVM, it prints one line and then crashes:
userId=null history=null repository=null Exception in thread "main" java.lang.NullPointerException: Cannot invoke "ProfileRepository.load(String)" because "this.repository" is null at ProfileViewModel.loadInitialData(Main.kt:21) at BaseViewModel.<init>(Main.kt:8) ...
All three properties are non-null types. All three are null. The repository case is the confusing one. It was passed in as an argument. That argument wasn’t null. The build printed no warning for either class. The second stack frame is the clue. The override ran inside BaseViewModel.<init>, the base class constructor.
A non-null Kotlin property is null because the base init runs first
The Kotlin inheritance docs spell out the order. Base class initialization happens first. Only the evaluation of the arguments to the base class constructor comes before it. The derived class’s initialization logic runs after it. The docs then warn about this exact case. The derived class’s properties aren’t initialized while the base constructor runs. The docs say using them from base class initialization “may lead to incorrect behavior or a runtime failure.” That holds for direct reads and for reads through an overridden open member.
Walk ProfileViewModel(FakeRepo()) through that order:
- The JVM allocates the object. Every field starts at its default. References are null. An
Intwould be 0. BaseViewModel‘sinitruns and callsloadInitialData(). The call dispatches to the override inProfileViewModel, because the object already is aProfileViewModel.- The override reads
userId,historyandrepository. Nothing has assigned them yet, so it sees three nulls and crashes onrepository.load(). - Had it survived,
ProfileViewModel‘s own initialization would run next. That’s whererepositorygets stored from the argument anduserIdgets"u-42".
Step 4 explains the constructor parameter. A private val in the primary constructor is a property. Its field is assigned in the subclass’s initialization, like any other property. The argument exists in step 2. The field just doesn’t hold it yet. A string literal doesn’t help either. "u-42" is a constant. Still, userId isn’t a const val. Its field gets the value in step 4, like the rest.
The compiler checks a direct read but not a read through a call
Kotlin does guard part of this. If an init block reads a property above its declaration, the build fails. With 2.4.20, the error is “variable ‘userId’ must be initialized.” If the same read moves into a function, the check stops:
class Early {
init { show() }
private val userId = "u-42"
private fun show() = println("init sees userId=$userId")
}
fun main() { Early() }
init sees userId=null
That compiles with no warning. There’s no inheritance in it at all. The check works inside one initializer, line by line. It doesn’t follow calls. An override in a subclass is even further out of its reach. When BaseViewModel compiles, the compiler doesn’t know which subclasses will exist.
Once the null is in the field, nothing stops it at the read. Kotlin trusts its own non-null types when a class reads its own field. So it adds no check there. The null moves on until a call on it fails. In the example, that’s repository.load(). A null userId passed to a Java method could travel further first. The Kotlin null safety docs list this among the few ways to get an NPE in Kotlin. They call it data inconsistency during initialization. One named case is a superclass constructor that calls an open member whose override uses uninitialized state.
Start the work after construction, from code that knows the order
The base class can’t make its subclasses safe to call during its own init. So it shouldn’t call them there. The inheritance docs give the same advice. Avoid open members in constructors, property initializers and init blocks. For the running example, the smallest fix moves the call into the final subclass:
abstract class BaseViewModel : ViewModel()
class ProfileViewModel(
private val repository: ProfileRepository,
) : BaseViewModel() {
private val userId = "u-42"
private val history = mutableListOf<String>()
init {
loadInitialData()
}
private fun loadInitialData() {
history += repository.load(userId)
println("history=$history")
}
}
fun main() {
ProfileViewModel(FakeRepo())
}
history=[Profile(u-42)]
Three details make this version safe.
ProfileViewModelis final, so no class below it can run code before its fields are set.loadInitialData()isprivate, so no subclass can override it.- The
initblock sits below the properties. The Kotlin classes docs cover the order.initblocks and property initializers run in the order they appear in the class body. TheEarlyexample shows what happens when the call comes first.
Two other shapes work when the base class needs to drive the flow. The first passes data up instead of calling down. BaseScreen(screenName: String) can use its own constructor parameter safely, because the base class assigns it before its init runs. The second makes the start explicit. The base class exposes a start() function. The owner calls it after construction. The base can then call open hooks from start(), because every field is set by then.
How to explain the crash in a bug-squash round
In a bug-squash round, this snippet would come with the stack trace above and one question. Why is a non-null property null? The post on how the Android debugging interview works covers how that round is scored. This is the explanation to give for this bug.
- Name the order. The base class’s
initruns before the subclass’s fields are assigned. The overriddenloadInitialData()runs inside that window. So it sees JVM defaults. That includes the constructor parameter. - Explain why the types didn’t catch it. The compiler checks direct reads inside one initializer, not reads through calls or overrides. Kotlin adds no null check when a class reads its own field. The Kotlin docs list this case as one of the few sources of an NPE.
- Give the fix. The base constructor stops calling overridable members. The final subclass calls a private function from an
initplaced after its properties. Name constructor parameters or an explicitstart()as the options when the base must control the flow. - Mention the property-order trap inside one class. An
initabove a property declaration, calling a function that reads it, fails the same way.
Step 2 shows you know where Kotlin’s null safety stops. A strong candidate can say both why it compiled and why it crashed. The same class can hide another inheritance trap. A data class’s equals() skips properties declared in its parent. See why a data class’s equals() ignores the parent class.
Four fixes that compile and still leave the bug
Each of these came out of the same kotlinc 2.4.20 run, applied to the ProfileViewModel hook.
- A safe call,
repository?.load(userId), stops the crash. The compiler warns about an “unnecessary safe call on a non-null receiver.” At runtime, the call is skipped. The screen never loads. Nothing reports why. - Making the property
by lazymoves the crash. A delegated property stores its delegate in a field too. That field isn’t assigned yet either. ReadinguserIdthroughby lazyfrom the hook throws an NPE:Cannot invoke "kotlin.Lazy.getValue()". - Assigning the property inside the hook loses the value. Suppose the hook sets
history = mutableListOf("opened"). The property itself is declared with= mutableListOf(). The hook runs first. Then the subclass’s initializer runs and replaces the list. After construction,historyis empty again. - A
lateinitproperty assigned in the hook does survive, because it has no initializer to overwrite it. But reading it in the hook before assignment throwsUninitializedPropertyAccessException, as the Kotlin properties docs describe. Every other property is still null in the hook. It works only while the hook touches nothing else.
All four treat the symptom in one property. The order stays broken for every property the hook might read next. Moving the call out of the base init fixes all of them at once. A null can also survive a null check in Compose. See the null check that didn’t save you.