A practical guide to variance, reified types, and type-safe design.

What You’ll learn
- How Kotlin generics improve code safety and reuse.
- Learn the difference between
in
,out
, and invariant types. - How
reified
solves runtime type erasure in inline functions. - What variance means and how it affects type relationships
- The difference between subclasses vs subtypes
Why Generics?
Generics are a powerful feature that let you write safer, more reusable and maintainable code. By allowing classes, functions, and interfaces to work with different types while preserving type safety, generics help eliminate the boilerplate and runtime casting errors.
Kotlin’s standard collections make heavy use of generics. Think about it: if we couldn’t define the type of items in a List
, it would be a nightmare — we’d constantly be doing dangerous type casts.
Another important issue Kotlin generics solves is variance. Consider these two classes:
interface Animal {
fun sound()
}
class Dog : Animal {
override fun sound() {
println("Bark")
}
}
class Cat : Animal {
override fun sound() {
println("Meow")
}
}
At first glance, you might assume that List<Dog>
is a sub type of List<Animal>
. However, this is only true because List
interface explicitly declares its generic type as covariant using out
keyword. Without this, the Kotlin compiler would treat List<Dog>
and List<Animal>
as unrelated types.
Just to clarify — we’re talking about subtypes here, not subclasses. They’re different concepts, and we’ll dive into that soon.
Deeply understanding generics will significantly improve your Kotlin code’s readability, safety and reusability.
Generic Type Parameters
Generic type parameters <T>
act as placeholders for actual types, allowing us to write reusable and type safe code. Every time an instance of a generic type is created, type parameters are replaced with specific types (known as type arguments).
We can define generics in either classes, interfaces, or functions.
Consider a simple generic class Farm
and a global generic function feed()
class Farm<T> {
private val animals: MutableList<T> = mutableListOf()
fun addAnimal(animal: T) {
animals.add(animal)
}
fun getAnimals(): List<T> {
return animals
}
}
fun <T> feed(animal: T) { ... }
Now, this is not very useful because the type parameter is not restricted, we can pass anything and it will compile. Furthermore, inside the feed()
function we can’t call Animal’s sound()
method because it is not constrained to be an animal, which leads us to type constraints.
Type Constraints
Constraints let us restrict the types that can be replaced as type arguments for a class or function.
We can define an upper bound constraint for a generic type parameter, and the corresponding type arguments during instantiation must be either the specified type or a subtype:
class Farm<T: Animal> {
...
}
Now, Farm
‘s generic type can only be an object implementing the Animal
interface, allowing us to safely access properties or methods defined in Animal.
For example:
fun <T: Animal> feed(animal: T) {
animal.sound()
}
Attempting to pass a type that doesn’t conform to this constraint will result in a compile-time error:
feed("Not an animal") // ❌ Compile-time error
By default, if no upper bound is specified, Kotlin assumes the upper bound to be Any?
, allowing nullable values. If we want to explicitly disallow null values, we can set the upper bound as Any
, ensuring that the generic type T
is always non-null.
Finally, remember that type parameters can be either provided or inferred by the compiler:
val farm = listOf(Dog) // inferred type: List<Dog>
val farm = listOf(Dog(), Cat()) // inferred type: List<Animal>
val farm: List<Animal> = listOf(Dog()) // explicit type
Type constraints are especially useful when building libraries or APIs where you want to restrict generic behavior to certain capabilities (like Comparable
andNumber
)
Reified and erased type parameters
At runtime, generics are erased. This means that at runtime we can’t know which type arguments were defined to create the instance. For example, if we create a List<String>
, at runtime we will not be able to identify the type of the elements contained within that list, we can only know it is a list of some unknown type.
The only one that can distinct List<String>
from List<Animal>
is the compiler, the runtime is not able to. You can easily check this by trying to compile the following code:
if (list is List<String>) { } // ❌ Cannot check for instance of erased type: List<String>
if (list is List<*>) // ✅ Valid using Star Projection, there is a specific type but we don't know it
The same limitation applies to generic functions:
fun <T> runIfIsType(value: Any, block: () -> Unit) {
if (value is T) { // ❌ Cannot check for instance of erased type: T
block()
}
}
Luckily, reified type parameters comes to the rescue by helping with this limitation in a very particular case: inline functions. The following code compiles:
inline fun <reified T> runIfIsType(value: Any, block: () -> Unit) {
if (value is T) {
block()
}
}
This works because inline functions are inserted in the place where they are called. Thus, the compiler can know the exact type used as the type argument in that particular call. This is the only case where we can use reified types.
Variance: subtyping
Previously, we said that List<Dog>
is a sub type of List<Animal>
because List
declares its generic type as covariant, let’s try to explain what this means.
What problems variance solves? well, what if we have a function that receives a MutableList<Any>
, is it safe to pass a MutableList<String>
?
It is not, the compiler will not allow us to do that because it would crash at runtime:
fun varianceExample1() {
val stringsList = mutableListOf("Hello", "World")
addIntToList(stringsList) // ❌ Compiler error: Type mismatch.
println(stringsList)
}
fun addIntToList(list: MutableList<Any>) {
list.add(1) // ❌ potential Runtime Exception
}
What if instead of a Mutable
list we haveList<String>
and List<Any>
?
fun varianceExample2() {
val stringsList: List<String> = mutableListOf("Hello", "World")
printList(stringsList) // ❌ Compiler error: Type mismatch.
}
fun printList(list: List<Any>) {
println(list)
}
Since the function that receives the list only reads from it and doesn’t modify it, it is completely safe. Note that it matters how a generic type is used: is it just receiving and reading a value or is it modifying it? Variance defines the relationship between generics
Subclass vs Subtype
These two relations are often confused or used interchangeably, but strictly speaking they are not the same.
A type
B
is a subtype of a typeA
if you can use the value of the typeB
whenever a value of the typeA
is required.
In other words: If x: B
, then x
can be used in any context where A
is expected. This is strongly related to Liskov substitution principle, one of the SOLID principles.
So, it is obvious that a subclass B is a subtype of its superclass A if no generics are involved, but things change when generics come to the scene. The subtyping relationship is determined by the variance on its generic type parameters.
Covariance
A covariant class is a generic class where the following is true:
Farm<A> is a subtype of
Farm<B>
ifA
is a subtype ofB
.
In Kotlin, to define a covariant class we use the out
keyword:
class Farm<out T: Animal>(private val animals: List<Animal>) {
fun feedAll() {
for (animal in animals) {
animal.feed()
}
}
}
By defining the class Farm
as covariant for its type T
, we can (for instance) pass a Farm<Cat>
to any place that requires a Farm<Animal>
:
fun visitFarm(farm: Farm<Animal>) {
farm.feedAll()
}
val catFarm: Farm<Cat> = Farm(listOf(Cat()))
// This is valid only because T was defined as covariant
visitFarm(catFarm)
But defining a type parameter as covariant is not free: it adds a limitation on how the generic class can use that type. In the case of covariance, this means that the type can only be used in the out/producer position in any of its externally visible methods (public, protected, internal)
To clarify: in public/protected/internal methods, T
can’t be a parameter and it can only be returned.
class Farm<out T : Animal>(private val animals: MutableList<T>) {
fun feedAll() {
for (animal in animals) {
animal.feed()
}
}
operator fun get(i: Int): T { // ✅
return animals[i]
}
fun remove(animal: T) { // ❌ Type parameter T is declared as 'out' but occurs in 'in' position in type T
animals.remove(animal)
}
private fun stroke(animal: T) { // ✅ OK because this method is private
}
}
Private methods are not affected by variance limitations because variance rules are designed to protect the class from incorrect usage by external callers. Since private methods aren’t accessible outside the class, the compiler allows more flexibility inside them.
Contravariance
Can be thought of as the opposite of covariance: the subtyping relationship of the generic class is reversed compared to the subtyping of its type parameters. 🤯
To clarify: if we define Farm
as contravariant on T
, then Farm<Animal>
is a subtype of Farm<Cat>
(if Cat
is a subtype of Animal
)
Contravariance on a type is declared by using the in
keyword. This also limits how we can use the T
type in our class: T
can only be in in
positions of externally visible methods.
class AnimalFeeder<in T: Animal> {
fun feed(animal: T) {
println("Feeding $animal")
}
}
fun visitDogFeeder(feeder: AnimalFeeder<Dog>) {
// ...
}
val animalFeeder = AnimalFeeder<Animal>()
visitDogFeeder(animalFeeder) // ✅ Allowed: contravariant
Note: A class can be covariant on one type and contravariant on another.
Summary
- At runtime, generic type parameters are erased. We can’t know the exact type of items contained in a
List<>
- The previous limitation can only be avoided using inline functions.
- Variance defines the subtyping relationship in generic type parameters. For instance: is
Farm<B>
a subtype ofFarm<A>
? out
keyword means Covariant:T
can only be used as a return type in public methodsin
keyword means Contravariant:T
can only be used as a parameter in public methods- By default generic type parameters are Invariant: meaning that
Farm<A>
andFarm<B>
are completely different types, even ifB
is a subtype ofA