If the assignment were allowed, holderB.push(B()) would silently put a B into what is actually a Holder<C> — breaking type safety.
val holderC: Holder<C> = Holder(C())
val holderB: Holder<B> = holderC // imagine this were allowed
holderB.push(B()) // putting a B into a Holder<C> — type violation!
val c: C = holderB.pop() // would also return a B as C — type violation!
C <: B holds — a C is a valid B, but not the other way around.
Holder<C> <: Holder<B> does not hold - Holder<T> can both consume and produce. Allowing either subtype direction would create a hole in the type system.
class C : B()
class Holder<T>(val value: T) { ... }
val holderC: Holder<C> = Holder(C())
val holderB: Holder<B> = holderC //ERROR: Type mismatch. Required: Holder<B>. Found: Holder<C>.
BUT
val holderB: Holder<B> = Holder(C())
Expected type on the left: Holder<B>
compiler infers T = B
the call is effectively Holder<B>(C())
class Holder<T> (var value: T?) {
fun pop(): T? = value.also { value = null }
fun push(newValue: T?): T? = value.also { value = newValue }
fun steal(other: Holder<T>) { value = other.pop() }
fun gift(other: Holder<T>) { other.push(pop()) }
}
Holder<Nothing> ≁ Holder<C> ≁ Holder<B> ≁ Holder<A> ≁ Holder<Any>
val holderB: Holder<B> = Holder(B())
val holderA: Holder<A> = Holder(null)
holderA.steal(holderB) // ERROR: Type mismatch. Required: Holder<A>. Found: Holder<B>.
holderB.gift(holderA) // ERROR: Type mismatch. Required: Holder<B>. Found: Holder<A>.
Invariance is enforced at every boundary — not just assignment, but also when passing generics as function arguments
inclass Holder<T> (var value: T?) {
fun pop(): T? = value.also { value = null }
fun push(newValue: T?): T? = value.also { value = newValue }
fun gift(other: Holder<in T>) { other.push(pop()) }
}
holderB.gift(holderA) // OK
Type projection: other is a restricted (projected) generic. Only methods where T appears in input (parameter) position are usable — in this case only push().
This is contravariance — the subtype hierarchy flips (:> means "is a supertype of"):
Nothing <: C <: B <: A <: Any
Holder<in Nothing> :> Holder<in C> :> Holder<in B> :> Holder<in A> :> Holder<in Any>
outclass Holder<T> (var value: T?) {
fun pop(): T? = value.also { value = null }
fun push(newValue: T?): T? = value.also { value = newValue }
fun steal(other: Holder<out T>) { value = other.pop() }
}
holderA.steal(holderB) // OK
Type projection: other is a restricted (projected) generic. Only methods where T appears in output (return) position are usable — in this case only pop().
This is covariance — the subtype hierarchy is preserved:
Nothing <: C <: B <: A <: Any
Holder<out Nothing> <: Holder<out C> <: Holder<out B> <: Holder<out A> <: Holder<out Any>
class Holder<T> (var value: T?) {
fun steal(other: Holder<out T>) {
val oldValue = push(other.pop())
other.push(oldValue) // ERROR: Type mismatch. Required: Nothing?. Found: T?.
}
fun gift(other: Holder<in T>) {
val otherValue = other.push(pop())
push(otherValue) // ERROR: Type mismatch. Required: T?. Found: Any?.
}
}
out T — readable as T?, not writable (write position becomes Nothing?)
in T — writable as T?, not readable (read position becomes Any?)
At runtime, generic type arguments are erased — all instances share the same bytecode regardless of their type parameter.
Unbounded T erases to Any?; bounded T : Movable erases to Movable.
At the JVM level, MutableMap<String, Int> and MutableMap<Long, Boolean>
are the same raw MutableMap — type arguments are gone.
In Kotlin source, this erased state is expressed as MutableMap<*, *>
Pilot<Car> and Pilot<Plane> both become Pilot<Movable> at the JVM level (erased to the bound).
As a corollary, you cannot override a function (in Kotlin/JVM) by changing generic type parameters:
fun quickSort(collection: Collection<Int>) { ... }
fun quickSort(collection: Collection<Double>) { ... }
Both become quickSort(collection: Collection<*>) and their signatures clash.
But you can use the JvmName annotation:
@JvmName("quickSortInt")
fun quickSort(collection: Collection<Int>) { ... }
fun quickSort(collection: Collection<Double>) { ... }
An unbounded type parameter T has an implicit upper bound of Any?, so nullable substitutions are allowed by default.
class Holder<T>(val value: T) { ... } // Notice there is no `?`
val holderA: Holder<A?> = Holder(null) // T = A? and that is OK
To prohibit nullable type arguments, constrain T with Any.
class Holder<T : Any>(val value: T) { ... }
val holderA: Holder<A?> = Holder(null) // ERROR: Expected: Any. Found: A?
T & Any is Kotlin's definite non-null type — the intersection of T and Any, which strips null from T even when T is nullable.
fun <T> elvisLike(x: T, y: T & Any): T & Any = x ?: y
Thanx!