Lecture notes — Generics
ver. 1.0.0
Where we are
Every unit so far has used generics without discussing them — List<String>, (Int) -> Int, a Map<String, Person>. This unit explains them.
It is worth knowing in advance that it contains three separable topics of very different difficulty:
- Declaring generics is mostly syntax, and you already know most of it from Java.
- Erasure is a JVM constraint. Generic type arguments do not exist at runtime, which makes certain reasonable-looking code impossible. Kotlin’s answer — reified type parameters — works only in inline functions, and only because of the inlining machinery from the previous unit.
- Variance is the hard part, and it is a genuine question rather than an arbitrary rule. If
Stringis a subtype ofAny, isList<String>a subtype ofList<Any>? The answer is sometimes.
Variance is the part people skip and then trip over, usually while trying to pass a List<Something> to a function that wants a List<SomethingElse> and being refused. Working through the reasoning once — rather than memorising which keyword goes where — is what makes the compiler’s messages legible.
What you will be able to do
declare-generic-functions-and-classes— Declare generic functions, classes and extension properties with type parameters.constrain-type-parameters— Constrain a type parameter with an upper bound, including a non-null bound.explain-type-erasure— Explain what type erasure removes and what that makes impossible.use-reified-type-parameters— Use a reified type parameter in an inline function to recover the type at runtime.explain-variance— Explain what variance is and why List<String> being a List<Any> is not obviously safe.use-covariance-and-contravariance— Declare a class covariant or contravariant with out and in.distinguish-declaration-and-use-site-variance— Distinguish declaration-site from use-site variance and choose between them.use-star-projections— Use a star projection when the type argument is unknown but irrelevant.
What we will cover
- Declaring generics — functions, classes, extensions, and upper bounds.
- Erasure — what the JVM removes and what becomes impossible.
- Star projections — checking a type without claiming to know its argument.
- Reified type parameters — and why they need
inline. - Variance — the problem, then
outandin, then projections.
Declaring generics
Generic functions
A type parameter in angle brackets before the function name, usable for parameters, the return type and locals:
fun <T> List<T>.slice(indices: IntRange): List<T>
val letters = ('a'..'z').toList()
println(letters.slice<Char>(0..2)) // explicit
println(letters.slice(10..13)) // inferred from the receiver
T appears in both the receiver type and the return type.The standard library’s collection functions are all declared this way — filter on a List<T> takes a predicate on T.
Generic extension functions and properties work the same way, which is how the collection extensions from unit 3 stay type-safe. An extension property may be generic; an ordinary top-level property may not, since there is nothing to infer the type from.
Generic classes declare their parameters after the class name:
interface List<T> {
operator fun get(index: Int): T
}
class StringList : List<String> { // supplies a concrete argument
override fun get(index: Int): String = ...
}
class ArrayList<T> : List<T> { // stays generic itself
override fun get(index: Int): T = ...
}Type inference usually supplies the argument from the receiver or the parameters, so you rarely write it. When there is nothing to infer from — an empty list, say — you supply it explicitly.
Type parameter constraints
An upper bound restricts what may be substituted, and in exchange the body may use that type’s members:
fun <T : Number> List<T>.sum(): T
fun <T : Comparable<T>> max(first: T, second: T): T =
if (first > second) first else secondWithout the bound, > would not compile — the compiler has no reason to think a bare T is comparable.
Multiple constraints need a where clause:
fun <T> ensureTrailingPeriod(seq: T)
where T : CharSequence, T : Appendable {
if (!seq.endsWith('.')) {
seq.append('.')
}
}An unbounded type parameter has upper bound Any? — so T is nullable, and inside the function you cannot call methods on a T without handling null:
fun <T> printHashCode(t: T) {
println(t?.hashCode()) // the ?. is required
}To require a non-null argument, write the bound explicitly:
fun <T : Any> printHashCode(t: T) {
println(t.hashCode()) // fine — T cannot be null
}That is the nullability discipline from unit 6 applied to type parameters: the permissive case is the default, and the guarantee must be asked for.
Learning outcomes
- declare-generic-functions-and-classes: Declare generic functions, classes and extension properties with type parameters.
- constrain-type-parameters: Constrain a type parameter with an upper bound, including a non-null bound.
Concepts
- generic-type-parameters: type parameters on functions, classes and extensions, usually inferred at the call site
- type-parameter-constraints: upper bounds,
whereclauses, and<T : Any>for non-null
Erasure and star projections
What erasure is

List<String> and List<Int> are both just List.Generics were added to Java after the fact, and for compatibility the type arguments are discarded during compilation. At runtime a List<String> and a List<Int> are both a List, and the JVM cannot tell them apart.
Kotlin inherits this, because it targets the JVM.
What it makes impossible
if (value is List<String>) { } // ERROR: cannot check for erased typeThree consequences:
value is List<String>does not compile. The check cannot be performed, so the compiler rejects it rather than letting you write something that would silently be weaker than it appears.- You cannot create an instance of a type parameter. There is no type at runtime to instantiate.
- Overloads differing only in type argument clash, since both erase to the same signature.
Star projections
if (value is List<*>) { } // this compilesList<*> means a list whose element type is unknown. Elements read from one are Any?, since nothing more is known, and you cannot write to it, since you do not know what would be acceptable.
List<*> is not List<Any?>
They are different statements.
List<Any?>says the element type isAny?— a list that genuinely accepts anything.List<*>says the element type is something specific that we do not know.
Confusing the two produces surprising compiler errors — most often, being refused a write to something you thought accepted anything.
When erasure is fine
Most of the time. The compiler checked the types when the code was compiled, and there is nothing left to verify.
It bites in exactly two places: runtime type checks and reflection — which is why the next section exists, and why the reflection unit returns to the subject.
Learning outcomes
- explain-type-erasure: Explain what type erasure removes and what that makes impossible.
- use-star-projections: Use a star projection when the type argument is unknown but irrelevant.
Concepts
- type-erasure: type arguments discarded at compile time, so runtime cannot see them
- star-projection: an unknown-but-specific type argument, which is not the same as
Any?
Reified type parameters
inline fun <reified T> isA(value: Any) = value is T
println(isA<String>("abc")) // true
println(isA<String>(123)) // falseIn an inline function, a type parameter may be marked reified, and the body may then use it as though the type were present at runtime — checking value is T, or referring to T::class.
Why it works
Inlining substitutes the function’s body at the call site — and the type argument is known at each call site. So the compiler substitutes the concrete type along with the body.
isA<String>(x) becomes, literally, x is String.
At runtime there is no type parameter to have been erased, because the generated code names the actual type.
That is also why the restriction exists: only inline functions can have reified parameters. A non-inlined function has one compiled body shared by all callers, and no single type to substitute.
This is the second capability in two units that inlining pays for. The previous unit’s non-local returns were the first.
What it enables
val strings = items.filterIsInstance<String>()filterIsInstance cannot be written without reification — it must ask, per element, whether it is a T.
And, more consequentially, reflection helpers that take a type as a parameter rather than a class object:
val person = parse<Person>(json) // Kotlin
Person p = parse(json, Person.class); // JavaWorth noticing now, because the reflection unit that follows is full of it. Knowing the mechanism means you can write your own such APIs rather than only using them.
A caution. Reification is not free: it requires inlining, and inlining a large function into every call site has the cost the previous unit described.
Keep reified functions small. If the body is big, extract the bulk into a non-inline helper and keep only the type-dependent part reified.
Learning outcomes
- use-reified-type-parameters: Use a reified type parameter in an inline function to recover the type at runtime.
Concepts
- reified-type-parameters: the type substituted at each call site, which is what inlining makes possible
Variance: the problem
Before any keyword, the question.

B is a subtype of A.Subtyping is familiar: a String may be used where an Any is expected. Generics raise the same question one level up.
If
Stringis a subtype ofAny, may aList<String>be used where aList<Any>is expected?
Why “obviously yes” is wrong
Suppose it were allowed, and consider this function:
fun addAnswer(list: MutableList<Any>) {
list.add(42)
}
val strings = mutableListOf("abc", "bac")
addAnswer(strings) // if this were allowed...
println(strings.maxBy { it.length }) // ...ClassCastExceptionA list declared to hold strings now holds an integer, and the failure surfaces far away, when something reads it.
So the answer depends on what the type does with its parameter
- If a list only ever produces elements, treating a
List<String>as aList<Any>is safe — everything that comes out is aString, and aStringis anAny. - If it also consumes elements, it is not safe, for the reason above.
Unit 6 separated read-only List from MutableList, and now the reason is visible:
Listonly produces, so it can safely be covariantMutableListconsumes, and cannot
That earlier design decision exists to make this one possible. It is the clearest example in the module of two chapters being the same idea seen from different ends.
The terms

A is a subtype of nullable A?.A generic type is:
- covariant in a parameter if subtyping is preserved
- contravariant if it is reversed
- invariant if neither
Java’s generics are invariant by default, which is why wildcards exist. The next section is Kotlin’s answer.
Learning outcomes
- explain-variance: Explain what variance is and why List<String> being a List<Any> is not obviously safe.
Concepts
- subtyping: the relation variance is asking about
- invariance: neither preserved nor reversed — Java’s default
out, in, and projections

Producer<T> preserves subtyping; contravariant Consumer<T> reverses it.out — covariance
interface Producer<out T> {
fun produce(): T
}Marking a type parameter out declares that the class only produces values of that type, never consumes them. In exchange, subtyping is preserved: a Producer<String> may be used where a Producer<Any> is expected.
The compiler enforces the promise with a position rule: an out parameter may appear only in out positions — return types — and not as a function parameter. That restriction is exactly what makes the covariance safe, since a consumer is the thing that could corrupt the collection.
in — contravariance
interface Comparator<in T> {
fun compare(e1: T, e2: T): Int
}The mirror image. An in parameter means the class only consumes values of that type, so subtyping is reversed: a Comparator<Any> may be used where a Comparator<String> is expected — because something that can compare any two objects can certainly compare two strings.
The position rule mirrors too: an in parameter may appear only in in positions — function parameters.
outfor producers,infor consumers.
And the position rule follows from it rather than being a second thing to remember: a producer’s type appears where values come out, a consumer’s where they go in.
Function types show both at once:

(T) -> R is contravariant in its parameter and covariant in its result.Which makes sense on inspection: a function that accepts more kinds of input and returns a more specific result can stand in for one that accepts fewer and returns something vaguer.
Declaration-site variance
This is where Kotlin improves on Java. Variance is declared once, on the class, and every use benefits.
Java has only use-site variance, so every user writes ? extends or ? super at every use — repetitive, easy to forget, and the source of signatures nobody enjoys reading:
void copyAll(Collection<? super T> to, Collection<? extends T> from)fun <T> copyAll(to: MutableCollection<in T>, from: Collection<T>)Use-site variance: type projections
Sometimes a class genuinely cannot be variant overall, because it both produces and consumes. MutableList is the example — it must be invariant.
But a particular function may only read from its parameter:
fun <T> copyData(source: MutableList<out T>, // projected: only produces
destination: MutableList<T>) {
for (item in source) {
destination.add(item)
}
}MutableList<out T> says “I only produce from this one”, and callers may then pass a list of a subtype. Inside the function, source.add(...) will not compile — which is the projection being enforced rather than merely documented.
Star projection fits here as the limiting case: <*> claims nothing about the type in either direction.
Most of the time you use variance without declaring any. The standard library already marks List as out T and Comparator as in T, so the right thing happens and you never notice.
You reach for the keywords when writing your own generic types. The question to ask is simply:
Does this type produce its parameter, consume it, or both?
Produce → out. Consume → in. Both → invariant, and project at the use sites that need it.
Learning outcomes
- use-covariance-and-contravariance: Declare a class covariant or contravariant with out and in.
- distinguish-declaration-and-use-site-variance: Distinguish declaration-site from use-site variance and choose between them.
Concepts
- covariance:
outpreserves subtyping, and may appear only in return positions - contravariance:
inreverses subtyping, and may appear only in parameter positions - declaration-site-variance: declared once on the class, unlike Java’s per-use wildcards
- use-site-variance: a projection for one function, where the class cannot be variant overall
What generics now let you express
Three ideas, and only the last is hard. Declaration is syntax. Erasure is a JVM constraint with a clever workaround. Variance is a genuine question about what is safe.
Learning outcomes
- declare-generic-functions-and-classes: Declare generic functions, classes and extension properties with type parameters.
- constrain-type-parameters: Constrain a type parameter with an upper bound, including a non-null bound.
- explain-type-erasure: Explain what type erasure removes and what that makes impossible.
- use-reified-type-parameters: Use a reified type parameter in an inline function to recover the type at runtime.
- explain-variance: Explain what variance is and why List<String> being a List<Any> is not obviously safe.
- use-covariance-and-contravariance: Declare a class covariant or contravariant with out and in.
- distinguish-declaration-and-use-site-variance: Distinguish declaration-site from use-site variance and choose between them.
- use-star-projections: Use a star projection when the type argument is unknown but irrelevant.
Conclusion
An unbounded
Tis nullable, because its upper bound isAny?.The single most common surprise in this unit. Write
<T : Any>when you need the guarantee — the permissive case is always the default in Kotlin’s type system.Erasure bites in exactly two places.
Runtime type checks and reflection. Everywhere else the compiler already did the work, and there is nothing to miss.
List<*>andList<Any?>are different claims.One says the element type is unknown; the other says it is genuinely
Any?. The first refuses writes for a reason.reifiedworks because the type argument is known at each call site.Inlining substitutes the concrete type along with the body, so nothing was un-erased — the type was simply never variable. Second capability in two units that inlining paid for.
Variance is a real question, and the mutable-list argument is the answer.
A function that adds an
Anyto a supposedList<Any>would corrupt aList<String>. Which is whyListcan be covariant andMutableListcannot, and why unit 6 split them in the first place.outfor producers,infor consumers, and the position rules follow.Declared once on the class in Kotlin, at every use in Java. When a class does both, project at the use site that only needs one direction.
Where next
The next unit, Annotations and Reflection, is about inspecting and acting on code at runtime.
Erasure returns immediately, since reflection is where the missing type information is most missed — and reified type parameters return as the thing that makes Kotlin’s reflection APIs pleasant to call.