1. Introduction
When a beginner first sees override, the reaction is often: “Why do I need to write this, the compiler can already see that I want to make the same method.” And this is where Kotlin acts like an adult: it forces you to explicitly confirm your intention. Because in real projects a “similar method” and an “overridden method” are different things, and the cost of a mistake is usually measured in hours of debugging.
In Kotlin, overriding is possible only if the member in the base class is marked as open, and in the subclass you write override. This rule is straightforward and strict: the compiler won’t let you accidentally “replace” a method with a typo in the signature or with a different type. Kotlin directly requires explicit modifiers for overridable members and for the overrides themselves.
A small demonstration (we continue a hypothetical console app for tracking money; let’s call it MoneyFlow):
open class Operation {
open fun title(): String = "Operation"
}
class ExpenseOperation : Operation() {
// fun title(): String = "Expense" // does not compile without override
override fun title(): String = "Expense"
}
Here it’s important to feel the idea: Kotlin isn’t “nitpicking”; it helps you avoid mistakes. If you really want to override, write override. If you wrote a method that is “accidentally similar”, the compiler will stop you.
2. Overriding methods: open fun in the base and override fun in the subclass
Usually the motivation for override is very practical: you made a base class that is “generally about operations”, and then you realized that expenses and income need to be described differently. Good style is to give the base class a contract (a method that “must exist”), and give subclasses the ability to implement it in their own way.
The rule is very simple: the base class method must be open, otherwise it can’t be overridden. Kotlin emphasizes separately: if a method is not open, then declaring a method with the same signature in a subclass is not allowed—neither with override nor without it.
Let’s make a base class Operation and a subclass. For now without “complex architecture”, just to get your hands used to it:
open class Operation(
val amountCents: Int,
) {
open fun signedAmountCents(): Int = amountCents
open fun renderLine(): String =
"Amount: ${signedAmountCents()} cents"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override fun signedAmountCents(): Int = -amountCents
override fun renderLine(): String =
"Expense: ${signedAmountCents()} cents"
}
Notice one useful habit: we don’t override “everything in sight”, only what really needs to differ. Inheritance is a tool, not a religion.
Let’s verify that it works:
fun main() {
val op = Operation(amountCents = 500)
val exp = Expense(amountCents = 500)
println(op.renderLine()) // Amount: 500 cents
println(exp.renderLine()) // Expense: -500 cents
}
Yes, we aren’t even discussing “smart collections of the base type” yet—that will be a separate topic. Right now it’s important to see the basic mechanism: the subclass really changes behavior via override.
3. Overriding properties: open val, type compatibility, and the val → var trick
Methods are “behavior”. But in Kotlin you can also override properties. And this is often even more practical than it sounds: the base class can promise “every operation has a kind”, and concrete operations clarify what exactly that kind is.
From the language perspective, properties are overridden almost the same way as methods: in the base class open val/var, in the subclass override val/var. The property override mechanism is described in the official inheritance rules: the type must be compatible, and you can write the override either as a property with initialization or as a property with a custom get().
Simple example: open val → override val
open class Operation(
val amountCents: Int,
) {
open val kind: String = "OPERATION"
}
class Income(amountCents: Int) : Operation(amountCents) {
override val kind: String = "INCOME"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override val kind: String = "EXPENSE"
}
Check:
fun main() {
val inc = Income(1200)
val exp = Expense(700)
println(inc.kind) // INCOME
println(exp.kind) // EXPENSE
}
Overriding via get(): when the value is computed
Sometimes you want to assemble the “operation type” from several parts. Then it’s convenient to override the property via a getter:
open class Operation(val amountCents: Int) {
open val kind: String
get() = "OPERATION"
}
class Expense(amountCents: Int, private val category: String) : Operation(amountCents) {
override val kind: String
get() = "EXPENSE($category)"
}
Test:
fun main() {
val exp = Expense(amountCents = 500, category = "Food")
println(exp.kind) // EXPENSE(Food)
}
Why you can override val with var (and why you can’t do the opposite)
This spot often feels like “magic”, but the explanation is pretty down-to-earth. val is essentially “has a getter”. var is “has a getter and a setter”. If the base class promised only a getter (val), the subclass can provide more capabilities and add a setter (var). The opposite direction is forbidden: if the base class promised a setter, the subclass has no right to “shrink the contract” and leave only the getter.
A short illustration:
open class Operation {
open val note: String = ""
}
class EditableOperation : Operation() {
override var note: String = "draft" // val -> var: allowed
}
4. Useful nuances: Kotlin 2.x and final override
Kotlin 2.x: open properties with a backing field are initialized immediately
Starting with Kotlin 2.0+, there is a rule that’s nice to know in advance so you don’t run into “why did it work yesterday but not today”. If a property is open and it has a backing field (that is, it’s not a purely computed get()), then it must be initialized immediately—at the declaration site, not “later in init”. In Kotlin 2.0 this became stricter: now even an open val with a backing field can’t be postponed until init.
Bad (or, more precisely, no longer allowed) style:
open class Base {
// open val a: Int // suppose we wanted this
// init { a = 1 } // Kotlin 2.x will say: not allowed
}
Good style is to provide the value right away:
open class Base {
open val a: Int = 1
}
Or make the property computed (without a backing field) if it’s truly computed:
open class Base {
open val a: Int
get() = 1
}
For learning projects this rule is especially useful: it forces you to write initialization so that behavior doesn’t depend on “subtle details” of object construction order.
final override: how to “close the door” after overriding
There’s a subtlety that’s very easy to miss: in Kotlin, a member marked with override itself becomes open for further overriding (unless you explicitly close it). This is logical: you continue the inheritance chain.
But sometimes you need the opposite: “at this level of the hierarchy we lock the implementation and don’t allow it to be changed further”. For this you use final override. If you need to forbid overriding further, use final.
Example in our “finance” theme:
open class Operation {
open fun kindCode(): String = "OP"
}
open class ExpenseBase : Operation() {
final override fun kindCode(): String = "EXP" // cannot be overridden further
}
class FoodExpense : ExpenseBase() {
override fun kindCode(): String = "FOOD" // does not compile
}
This is handy when you want to stabilize behavior at a certain level. For example, all expenses must have code "EXP", and the details “food/transport” will be stored in a separate property (instead of breaking the shared contract).
5. protected: visible to subclasses, but not visible from the outside
Up to this point we discussed “how to change behavior”. But inheritance quickly leads to another problem: a subclass sometimes needs to use some internal details of the base class, while you don’t want to make those details public (because then anyone will start using them and you’ll lose control).
That’s what protected is for. It’s a visibility modifier with a clear meaning: the member is accessible inside the class and inside subclasses, but not accessible to external code. It’s one of the main tools to prevent inheritance from turning into “handing out your insides to everyone who asks”.
To make this easier to keep in mind, here is a small table:
| Modifier | Access inside the class | Access in a subclass | Access “from the outside” |
|---|---|---|---|
|
yes | no | no |
|
yes | yes | no |
|
yes | yes | yes |
|
yes | yes | yes (but only within the module) |
Now let’s apply this to our example: let the base class be able to format an amount into a “normal form” and clean up a note. We want subclasses to be able to use it, but not external code.
open class Operation(
val amountCents: Int,
note: String,
) {
// Store the "raw" note, but do not expose it outside
protected var normalizedNote: String = note.trim()
protected fun formatMoney(): String {
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
open fun renderLine(): String =
"Amount: ${formatMoney()} USD, note='$normalizedNote'"
}
class Expense(amountCents: Int, note: String) : Operation(amountCents, note) {
override fun renderLine(): String {
// The subclass can see protected elements and use them
normalizedNote = "[EXP] $normalizedNote"
return "Expense: -${formatMoney()} USD, note='$normalizedNote'"
}
}
Let’s check:
fun main() {
val exp = Expense(amountCents = 2599, note = " lunch ")
println(exp.renderLine()) // Expense: -25.99 USD, note='[EXP] lunch'
println(exp.normalizedNote) // does not compile: protected
println(exp.formatMoney()) // does not compile: protected
}
That’s the good compromise: subclasses get “tools” and access to the required internal piece of state, while external code sees only the public contract.
One more interesting point: if you write an extension function outside the class, it can’t “break into” the private/protected members of that class—extensions work from the outside and follow visibility rules. This is useful to remember when you feel the urge to “bypass” encapsulation via an extension.
6. Practical example: contract + override + protected
Let’s put everything together into one small “verification” program to see that the lecture topics work together in real code rather than living in a vacuum.
open class Operation(val amountCents: Int) {
open val kind: String = "OP"
protected fun formatMoney(): String {
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
open fun renderLine(): String = "$kind ${formatMoney()} USD"
}
class Income(amountCents: Int) : Operation(amountCents) {
override val kind: String = "IN"
override fun renderLine(): String = "$kind +${formatMoney()} USD"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override val kind: String = "EX"
override fun renderLine(): String = "$kind -${formatMoney()} USD"
}
fun main() {
val a = Income(1250)
val b = Expense(399)
println(a.renderLine()) // IN +12.50 USD
println(b.renderLine()) // EX -3.99 USD
}
Here you can see three key ideas at once: the base class defines the API “skeleton”, subclasses refine the details via override, and shared useful logic stays in the base class and is available to subclasses via protected without becoming public.
7. Common mistakes with override and protected
Mistake #1: trying to override something that is not open.
This is the most common story: you write override fun … in a subclass, and the compiler says there’s nothing to override. Usually the reason is that you forgot to mark the base method (or property) as open. In Kotlin, “closed by default” is the normal state, and open should be added deliberately.
Mistake #2: “I definitely overrode it”, but the signature is slightly different.
Sometimes the parameter type, argument order, or return type changes—and suddenly it’s no longer an override but a completely different method. Kotlin partly saves you by requiring override: if the signature doesn’t match, the code simply won’t compile. But beginners sometimes remove override “so it works somehow” and end up with two different methods, one of which no one calls. The best cure is not to fight the compiler, but to read the error and compare signatures.
Mistake #3: expecting that override “is final by default”.
Beginners sometimes think: “Well, I already overrode it, so further overriding should be forbidden.” In Kotlin it’s the opposite: an overridden member usually remains open for further overrides, and if you want to lock the implementation, you need final override.
Mistake #4: postponing initialization of an open property until init (Kotlin 2.x).
Based on old experience from other languages, you may want to declare an open val x: Int, and assign a value in init. In Kotlin 2.x this can break: open properties with a backing field must be initialized immediately. If you need deferred computation, it’s better to make a computed get() or change the design (for example, remove open if it’s not actually needed).
Mistake #5: making a field public because “the subclass needs it”, and accidentally exposing internals to the whole world.
This is an architectural mistake, not a syntactic one. If external code doesn’t need to touch an internal variable or helper method, but a subclass does—that’s exactly what protected is for. This way you keep the possibility of extension without turning the class into a “showcase of the internal kitchen”.
Mistake #6: trying to reach protected via an extension function “from the outside”.
Sometimes it seems like an extension is like “adding a method directly to the class”, so you can access protected. But an extension lives outside the class and follows visibility: from the outside, protected is not accessible. If you want to use an internal detail, then either do it inside a subclass, or reconsider the API.