Subtyping in Kotlin: The Three Golden Rules for Safer, Flexible Code
Master the logic behind inheritance and generics using variance and the Liskov Substitution Principle.
Understanding subtyping is the “secret sauce” for writing flexible, professional Kotlin code. It’s the logic that determines when one type can safely stand in for another. While it sounds academic, these rules are the foundation of everything from basic inheritance to complex generics.
Let’s break down subtyping through the three rules that govern how Kotlin decides if your code is safe.
The Three Golden Rules of Subtyping
For a type Sub to be a valid subtype of Super (meaning you can substitute Sub wherever Super is expected), it must follow these three laws for every public function.
Rule 1: Same Operations (The Contract)
A subtype must offer at least all the public operations that its supertype provides. If you expect a Machine to start(), any subtype of Machine must also be able to start().
interface Beverage {
fun pour()
}
class Coffee : Beverage {
override fun pour() { println("Pouring dark roast...") }
fun brew() { println("Brewing...") } // Subtypes can add more!
}Kotlin’s Take: The compiler enforces this automatically. You cannot implement an interface or extend a class without satisfying its method requirements.
Rule 2: Contravariant Parameters (The Inputs)
Theoretically, for a method to be “safer” in a subtype, it should be able to handle more general inputs. If a Baker can handle Flour, a MasterBaker should be able to handle AnyIngredient.
The Logic: Contravariance is a property of types, not method overrides. While the theory says it’s safe to accept broader types, Kotlin prioritizes consistency in its overrides.
⚠️ The Kotlin Catch: Kotlin does not allow you to change parameter types when overriding methods.
open class Oven {
open fun bake(item: Flour) { ... }
}
class ProOven : Oven() {
// ❌ This is NOT allowed in Kotlin overrides!
// Parameters must remain invariant (the exact same type).
override fun bake(item: Ingredient) { ... }
}Instead, Kotlin exposes contravariance through generics using the in keyword.
Rule 3: Covariant Return Types (The Outputs)
This is the most intuitive rule: A subtype’s method must return a type that is the same as or more specific than the supertype’s return type. If a Factory produces a Vehicle, a TeslaFactory can specifically produce a ModelS.
open class Result
class Success : Result()
interface Task {
fun run(): Result
}
class CriticalTask : Task {
// ✅ Valid: Success is a subtype of Result.
// This is a "Covariant Return Type."
override fun run(): Success = Success()
}Variance: Rules in Action
Kotlin uses in and out to apply these rules to generic classes.
1. out (Producer - Covariance)
When you use out, you promise the compiler that the class will only output (return) the type T. This satisfies Rule 3.
interface Source<out T> {
fun produce(): T
}
val petStore: Source<Dog> = DogStore()
val animalStore: Source<Animal> = petStore // ✅ Safe: Every Dog is an Animal2. in (Consumer - Contravariance)
When you use in, you promise the class will only consume (input) the type T. This satisfies Rule 2.
interface Consumer<in T> {
fun consume(item: T)
}
val generalProcessor: Consumer<Animal> = AnimalProcessor()
val dogProcessor: Consumer<Dog> = generalProcessor // ✅ Safe: Consumers are contravariantUse-Site Variance: Type Projections
Sometimes you use a class you didn’t write, which isn’t marked with in or out. You can project variance at the moment you use it.
The out Projection
MutableList<out Animal> tells Kotlin: "I only want to read from this list."
- Crucial Note: This restriction applies even to
MutableList, which is otherwise invariant. By addingout, you gain the ability to treat aMutableList<Cat>as aMutableList<out Animal>, but you lose the ability to.add()anything to it.
The in Projection
MutableList<in Dog> tells Kotlin: "I only want to put Dogs into this list."
- Handling Returns: Kotlin restricts reading values of type
Tand widens the return type (typically toAny?) to prevent unsafe assumptions. Because the list could actually be aMutableList<Animal>, Kotlin cannot guarantee thatget()will return aDog.
Star Projections (*)
List<*> behaves like List<out Any?> in most cases. You can safely read values as Any? (the top of the type hierarchy), but you cannot write into it. It is the ultimate "read-only" safety net.
Frequently Asked Questions (FAQs)
Why doesn’t Kotlin allow contravariant overrides if the rule says they are safe?
It’s a design choice to avoid complexity and ambiguity. Kotlin prefers to handle parameter variance through generics (in), keeping method signatures predictable.
What is the “Liskov Substitution Principle” I keep hearing about?
It’s the academic name for these rules! It states that if S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program.
When should I use star projection over out Any??
Use star projection (*) when you genuinely don't know or care about the type argument. It’s cleaner and tells other developers, "I'm just looking at the structure, not the specific data."
Let’s Discuss!
- Which of the three rules was most surprising to you?
- Do you prefer defining variance at the interface level (
declaration-site) or when calling functions (use-site)? - What’s the most confusing “Type Mismatch” error you’ve ever encountered?
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.

Comments
Post a Comment