1. Introduction
In the previous lecture, we learned how to override methods and properties using override and how to open the necessary extension points via open. We also got familiar with protected as “accessible to subclasses, but not to the outside world”. Today we will add an important practical skill: how not to break the base logic when you override behavior, and why sometimes super is not “I gave up”, but “I did it right”. Plus, we will go over initialization order—the thing that often makes beginners feel like “Kotlin is trolling me”.
When you override a method, the temptation is: “that’s it, I’m in charge now, the base class can retire”. But in real code, the base class often does something important: validates inputs, normalizes text, writes logs, calculates some fields, maintains invariants. If you “cut out” that behavior, the program may start behaving oddly—without any compilation errors. And this is where super acts like a well-mannered person who says: “first we do the base part, then we add mine”.
In Kotlin, the keyword super lets you, from an overridden member, refer to the base class implementation (a method or a property accessor). The official wording usually sounds roughly like: “code in a subclass can call the superclass implementation via super”.
The most important idea: super is not always needed, but if the base class performs mandatory steps, super is a way to extend, not break.
2. super in an overridden method
Usually, life doesn’t ask us to “replace the method completely”, but rather “do the same thing, but also a little extra”. For example, the base class already knows how to print a message, and the subclass wants to add a prefix. Or the base class validates a string, and the subclass adds one more check and still wants to use the base one.
First, let’s look at a minimal example to build intuition:
open class Logger {
open fun log(message: String) {
println("[LOG] $message")
}
}
class TaggedLogger : Logger() {
override fun log(message: String) {
super.log("TAG: $message")
}
}
fun main() {
val logger = TaggedLogger()
logger.log("Hello")
// [LOG] TAG: Hello
}
Here the subclass doesn’t reinvent printing—it uses the existing “how to output the log” and adds its own part.
Now let’s move the idea into our learning context (a console expense-tracking application). Imagine we want to print “report lines”. The base class is responsible for the common format (for example, consistent indentation and symbols), and subclasses add the specific content.
open class ReportLine {
open fun render(): String = "- (empty)"
}
class TextLine(private val text: String) : ReportLine() {
override fun render(): String = super.render() + " $text"
}
fun main() {
val line = TextLine("hello")
println(line.render())
// - (empty) hello
}
The example is intentionally a bit “dumb”, but it shows the mechanics: super.render() is the base part of the format, and then we add our piece. In real life, the base render() will do something more useful than printing "empty", and that’s exactly why it’s beneficial to extend via super rather than copy formatting across all subclasses.
A slightly more realistic variant: the base class guarantees that a report line is never empty (for example, it returns "(no data)"), and the subclass adds details without forgetting that contract.
3. super and properties: accessing the getter
Beginners often feel that a property is “just a field”, and a method is “logic”. In Kotlin, that’s a dangerous illusion: a property almost always means a getter (and sometimes a setter)—that is, “a small method under the hood”. So super.someProp is not magic; it is “call the base class getter implementation”.
In inheritance docs, this is usually shown like this: a subclass can refer to a superclass property via super.property and use it as part of its logic.
Example:
open class Document {
open val title: String = "Untitled"
}
class NamedDocument(private val name: String) : Document() {
override val title: String
get() = super.title + ": " + name
}
fun main() {
val doc = NamedDocument("Budget report")
println(doc.title)
// Untitled: Budget report
}
Notice the detail: we overrode the val title not as a “stored value”, but via get(). That’s convenient when the “value” should be computed rather than stored. And yes, super.title here calls the base getter.
In real projects, this is often used to “extend the description”. The base class provides the common part, and the subclass appends a suffix. And if the base class description is also computed (not just stored), super helps you reuse that base logic.
4. Initialization order in inheritance
Before we get to the pitfalls, let’s understand in which order an object is created when there is a base class and a subclass. Many inheritance bugs look like “why is my field still 0/null, I assigned it!”, and the answer is almost always initialization order.
The intuitive rule is: when creating a subclass object, the “base part” is created first, and only then the “subclass part”. That sounds logical, but there are important nuances: before initializing the base class, Kotlin also evaluates arguments for the call to its constructor. Kotlin describes it directly: base initialization runs first (except for evaluating arguments for the superclass constructor), which means that properties overridden in the subclass are not yet initialized when the base constructor code or init runs.
Let’s look at a demo example that prints the steps. It intentionally uses also { println(...) } to show “exactly when what was evaluated”:
open class Base(name: String) {
init {
println("Base.init, name=$name")
}
open val size: Int = name.length.also {
println("Base.size initialized: $it")
}
}
class Derived(name: String, private val lastName: String) : Base(
name.uppercase().also { println("Argument for Base: $it") }
) {
init {
println("Derived.init, lastName=$lastName")
}
override val size: Int = (super.size + lastName.length).also {
println("Derived.size initialized: $it")
}
}
fun main() {
println("Constructing...")
Derived("hello", "world")
}
Expected output (roughly like this; the order matters more than the exact text):
Constructing...
Argument for Base: HELLO
Base.init, name=HELLO
Base.size initialized: 5
Derived.init, lastName=world
Derived.size initialized: 10
If we extract the “skeleton” of what happens, we get the following scheme:
flowchart TD
A["Create Derived(...)"] --> B["Evaluate arguments for Base(...)"]
B --> C["Initialize Base: properties + init blocks"]
C --> D["Initialize Derived: properties + init blocks"]
That is why the base class must be careful: during its initialization, the subclass is not “assembled” yet.
By the way, if you want to quickly check the order in your own code, println in init { } is an honest debugging tool. Kotlin doesn’t judge (your future team lead might judge a little, but that’s another story).
5. Why open calls in init are dangerous
Now we’re approaching the point where inheritance stops being a cute kitten and becomes a kitten that knocks your mug off the table because “what are you going to do about it”. Here’s the problem: if the base class, inside init, calls an open method or accesses an open property, Kotlin may call the override in the subclass even though the subclass hasn’t finished initializing its fields yet.
This is not a “compiler quirk”, but a logical consequence of polymorphism: the call must go to the most specific implementation. The documentation explicitly warns: when designing a base class, you should avoid using open members in constructors, property initializers, and init blocks.
Let’s look at an example that looks innocent but can crash:
open class BaseGreeter {
init {
// Bad idea: an open method will be called at a moment
// when the subclass may not be ready yet.
println(buildGreeting())
}
open fun buildGreeting(): String = "Hello from Base"
}
class FancyGreeter : BaseGreeter() {
private val prefix: String = "[Fancy]"
override fun buildGreeting(): String = "$prefix Hello from Fancy"
}
fun main() {
FancyGreeter()
}
At first glance everything seems fine: prefix is declared. But the order is such that BaseGreeter.init runs before FancyGreeter is initialized (its properties and its init). In some cases, this will cause the override to try to use state that is not ready yet.
To make the problem maximally obvious and to reliably get an error, we’ll use lateinit. This is a realistic situation: you postpone initialization, but on an early call you’ll get an exception.
open class BaseFormatter {
init {
println(format()) // risk
}
open fun format(): String = "(base)"
}
class UserFormatter : BaseFormatter() {
private lateinit var userName: String
init {
userName = "Alice"
}
override fun format(): String = "User=$userName"
}
fun main() {
UserFormatter()
// UninitializedPropertyAccessException: lateinit property userName has not been initialized
}
That’s the exact “why did it even go into the subclass” moment. It went there because the method is open, and the real object is UserFormatter.
How do you fix it? The idea is simple: the base class must not require overriding for its initialization. One safe technique is to make a final method in the base class, call it in init, and move the extensibility into a separate hook that you call only after the object is fully constructed.
For example, the base init prints only the base part, and the “additional” part can be called after creating the object (from external code or from the subclass init, once it’s ready).
open class SafeBaseFormatter {
init {
println(baseFormat()) // base part, not open
}
fun baseFormat(): String = "(base safe)"
}
class SafeUserFormatter : SafeBaseFormatter() {
private val userName: String = "Alice"
fun fullFormat(): String = "User=$userName"
}
fun main() {
val f = SafeUserFormatter()
println(f.fullFormat()) // User=Alice
}
Yes, it’s a bit less “magical”, but it’s predictable. And predictability is basically the main currency in programming (after coffee).
6. Mini step in the project: formatting report lines
To reinforce the topic with something more concrete than abstract “rectangles”, let’s plug super into a small fragment of our console expense-tracking application. Imagine we already have a list of expenses and we print a report. We want all report lines to share a common style: indentation, a marker, a length limit—and we want subclasses to add details without copying that style by hand.
Let’s make a base class ReportLine that knows how to build the “line header”, and subclasses add the content:
open class ReportLine {
protected fun bullet(): String = "•"
open fun render(): String = "${bullet()} "
}
class TextReportLine(private val text: String) : ReportLine() {
override fun render(): String = super.render() + text
}
fun main() {
println(TextReportLine("Expenses").render())
// • Expenses
}
Here you can already see the two ideas of the day: super.render() adds the common part, and protected keeps the “service” bullet() accessible to subclasses but not to the outside world.
Now let’s add a “total” line, where we want to strengthen the format: keep the base part common, but add the word "TOTAL" on top. This is intentionally to show that you can call super even in the middle of the string—this is just a normal function call.
class TotalReportLine(private val total: Int) : ReportLine() {
override fun render(): String = super.render() + "TOTAL = $total"
}
fun main() {
println(TotalReportLine(1200).render())
// • TOTAL = 1200
}
And now an example where super is used in properties, because it’s also convenient to make the “title” a property:
open class TitledLine {
open val title: String = "line"
open fun render(): String = "[$title]"
}
class CategoryLine(private val category: String) : TitledLine() {
override val title: String
get() = super.title + ":" + category
}
fun main() {
println(CategoryLine("Food").render())
// [line:Food]
}
Here super.title accesses the base getter. Even if the base title becomes computed, you won’t need to rewrite the subclass code.
And a small practical takeaway: super helps ensure the shared report style lives in one place. When you decide to change the marker from "•" to "-", you won’t have to hunt down 15 copies of formatting across the project.
When to call super and when not to
In real life, “always call super” is bad advice, and “never call super” is also bad advice. You need a clear criterion: is the base implementation part of a mandatory contract, or are you truly replacing it?
It helps to keep this table in mind:
| Situation | What it usually means | What to do most often |
|---|---|---|
| The base method validates data, logs, updates a counter | Important infrastructure; without it the class may be “broken” | Call super and add your logic before/after |
| The base method returns a “stub” or default | The base carries no meaning; it’s just “so something exists” | You can fully replace it without super |
| The override changes formatting, but the overall frame is the same | The shared part is needed by all subclasses | super + add-on |
| The base class calls an open method in init | Risk of early subclass execution | Redesign: super won’t save you here |
And one separate rule that’s worth putting on a sticky note: if the base class does something important for correctness and you don’t call super, your code may work “almost always”—and that’s the most treacherous failure mode.
7. Common mistakes
Mistake #1: “I overrode the method, so the base logic is no longer needed.”
This approach often breaks base-class invariants: for example, it normalizes input, maintains a counter, fills internal fields, or prints the mandatory part of a report. If you don’t call super, you’re not just “changing behavior”—you might accidentally cancel an important part of the contract.
Mistake #2: calling super “in the wrong place”, which makes the logic weird.
Sometimes the order matters: the base method may assume its call is the first step (for example, it validates arguments), or, наоборот, the last step (for example, it finalizes state). If you call super after you have already performed actions that depend on base preparation, you get bugs like “everything seems right, but the result is off”.
Mistake #3: accessing super.someProperty and expecting it to be just a field, not computation.
In Kotlin, a property is essentially the get()/set() methods. super.title may be computed, may refer to other fields, and may even have side effects (although it shouldn’t). So you should treat super.property as calling logic, not reading memory.
Mistake #4: calling open methods/properties from the base class init.
This is one of the nastiest “professional” bugs: the code compiles, sometimes it even runs, but as soon as the subclass becomes more complex it starts crashing because fields “are not ready yet”. Kotlin explicitly warns about this pattern: when designing a base class, it’s better to avoid open calls in the constructor and init.
Mistake #5: trying to “fix” initialization order problems with “one more lateinit”.
lateinit is a useful tool, but it doesn’t make initialization order safer. On the contrary: if an open call happens too early, you’ll get an exception. The right fix is not to “delay initialization even harder”, but to design the base class so it does not require overriding for its own construction, taking into account initialization order in the hierarchy.