skip to content

What is covariant return-type overriding in Kotlin, and what subtle correctness pitfalls arise when an open member is called during a superclass constructor while a subclass override depends on uninitialized state?

level: principalimportance: nice to knowfreq 30%

answer

  1. Covariant return: override returns a subtype
  2. Parameters stay invariant
  3. Open call in constructor → subclass override runs early
  4. Sees null / UninitializedPropertyAccessException
  5. Fix: make final, or defer init

basics

~20 s

An override may return a more specific subtype than the parent (covariant return). Danger: if a parent constructor calls an open method, the subclass override runs before the subclass's own fields are set, so it may see null/zero values.

solid answer

~50 s

Kotlin supports **covariant return types**: an overriding function (or property getter) may return a subtype of the parent's declared return type — e.g. `override fun copy(): Derived` over `open fun copy(): Base`. Parameter types, by contrast, are invariant. The classic correctness pitfall is **calling an `open` member from a constructor or initializer**: during superclass initialization the dynamic type is already the subclass, so an overridden method runs *before* the subclass's properties are initialized. The override then observes default/uninitialized values (e.g. a `lateinit` not yet set, throwing `UninitializedPropertyAccessException`, or a `val` still null). IntelliJ flags this as 'Calling non-final function in constructor'. The fix is to avoid open calls in initialization, mark such members `final`, or defer the work to an explicit post-construction `init`-style method. `super` still dispatches statically, but `this`-bound open calls in constructors are the trap.

code

kotlin · 9 lines
kotlin
open class Base {
    init { setup() }                 // BAD: open call in constructor
    open fun setup() {}
}
class Child : Base() {
    private val name: String = "child"
    override fun setup() = println(name.length) // NPE-like: name not set yet
}
// Fix: mark setup() final, or call it after construction completes.

go deeper

for a junior

May not know covariant returns or the constructor trap at all.

for a middle

Knows covariant return exists; may not foresee the uninitialized-state pitfall.

for a senior

Explains covariant return and recognizes the open-call-in-constructor bug and how to avoid it.

for a principal

Designs hierarchies to prevent the trap (final members, deferred init, factories) and reasons about substitutability and API contracts.

## Covariant return types When overriding, Kotlin lets the override **narrow the return type** to a subtype of the parent's return type. This is *covariant return-type overriding*: ```kotlin open class Animal { open fun reproduce(): Animal = Animal() } class Cat : Animal() { override fun reproduce(): Cat = Cat() // narrower return — legal } ``` Callers holding an `Animal` still see `Animal`; callers holding a `Cat` get the precise `Cat`. Parameters remain **invariant** — you cannot widen or narrow a parameter type in an override (that would create an overload, not an override). Property getters follow the same covariant rule on their type. ## The constructor-call pitfall The deeper trap is **dynamic dispatch during construction**. When you create a `Subclass`, the **superclass constructor/initializer runs first**, but the object's runtime type is already `Subclass`. So if the superclass constructor (or a property initializer) calls an `open` member via `this`, Kotlin dispatches to the **subclass override** — which executes *before* the subclass's own properties are initialized. ```kotlin open class Base { open val tag: String = "base" init { println("Base sees: ${describe()}") } // calls overridden describe() open fun describe() = "tag=$tag" } class Derived : Base() { val extra: String = "derived-data" override fun describe() = "tag=$tag extra=$extra" // extra not yet set! } // new Derived(): Base.init runs describe() -> Derived.describe() // but `extra` is still null at that moment -> prints 'extra=null' ``` Consequences: - A non-null `val` can be observed as `null`. - A `lateinit var` throws `UninitializedPropertyAccessException`. - Default-zero numerics appear instead of computed values. IntelliJ/Kotlin tooling warns: **"Calling non-final function in constructor"** and **"Accessing non-final property in constructor"**. ## Mitigations - **Make the member `final`** so no override can be dispatched (`final fun describe()` or a `final override`). - **Avoid open calls in initialization** — do not call overridable members from constructors, `init` blocks, or property initializers. - **Defer to an explicit start/configure method** invoked after the object is fully built. - **Use a factory** that constructs then initializes in a controlled order. ## Why `super` is not the same trap `super.member()` is **statically bound** to the named supertype implementation, so it never re-enters a subclass override. The pitfall is specifically `this`-dispatched `open` calls during construction.

  • Why can an override narrow the return type but not the parameter types?
    Narrowing the return is safe (Liskov: callers get at least what they expect). Changing a parameter type would break substitutability and instead defines an overload, not an override.
  • How do you safely run subclass-dependent initialization that a base class triggers?
    Avoid open calls in constructors; expose an explicit `initialize()`/`start()` method called after the object is fully constructed, or make the member `final`.

Like asking a half-built house to describe its furniture — the rooms (subclass fields) are not furnished yet.

saying these in an interview costs you the question

  • Claiming you can change parameter types in an override
  • Not recognizing that overrides dispatch during superclass construction
  • Calling open methods from init blocks without concern
  • Assuming a non-null `val` can never be observed as null

context