Generic Classes and Functions
Why Generics?
Without generics, you write separate implementations for every type or use Any (which loses type safety). Generics let you write a single class or function that works with any type while preserving compile-time type checking.
// Without generics: type-unsafe
class Box {
var value: Any? = null
}
val box = Box()
box.value = "hello"
val text: String = box.value as String // Unsafe cast
// With generics: type-safe
class Box<T>(var value: T)
val box = Box("hello")
val text: String = box.value // No cast needed
Generic Classes
A generic class accepts a type parameter (conventionally T, E, K, V):
class Pair<A, B>(val first: A, val second: B)
val pair = Pair(1, "one") // Pair<Int, String>
println(pair.first) // 1 (Int)
println(pair.second) // one (String)
Type parameters can be constrained. If a function needs to call methods on the type, add an upper bound:
class StringBox<T : String>(val value: T) {
fun length() = value.length
}
val box = StringBox("Hello")
println(box.length()) // 5
// val intBox = StringBox(42) // Compile error: Int is not a String
The : String constraint means T must be String or a subtype of String.
Generic Functions
Generic functions declare type parameters in angle brackets before the function name:
fun <T> singletonList(item: T): List<T> {
return listOf(item)
}
val list = singletonList(42) // List<Int>
val names = singletonList("Kotlin") // List<String>
Type inference usually determines T automatically. When it cannot, specify explicitly:
fun <T> emptyList(): List<T> = emptyList()
val empty = emptyList<String>() // Explicit type needed
Generic Constraints
Use where to require multiple bounds:
fun <T> process(item: T) where T : Comparable<T>, T : Serializable {
// T must implement both Comparable and Serializable
}
// Or with a single bound:
fun <T : Comparable<T>> sort(list: List<T>): List<T> {
return list.sorted()
}
Variance: In and Out
The Variance Problem
Consider a simple holder class:
class Box<T>(val value: T)
If Dog is a subtype of Animal, is Box
Out (Covariance)
Mark a type parameter out if the class only produces (reads) values of type T. This makes the generic covariant: Box
interface Source<out T> {
fun next(): T
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // Allowed because of out
}
With out, T can only appear in output positions (return types). You cannot pass T as a function parameter.
In (Contravariance)
Mark a type parameter in if the class only consumes (writes) values of type T. This makes the generic contravariant: Box
interface Comparable<in T> {
fun compareTo(other: T): Int
}
fun demo(x: Comparable<Animal>) {
x.compareTo(Dog()) // Allowed because of in
}
With in, T can only appear in input positions (function parameters). You cannot use T as a return type.
Star Projection
Use * when you do not care about the type parameter, similar to ? in Java wildcards:
fun printAll(list: List<*>) {
for (item in list) {
println(item)
}
}
printAll(listOf(1, "hello", 3.14))
Type Erasure
At runtime, generic type information is erased. You cannot check T is String at runtime. Use reified type parameters with inline functions to preserve type information:
inline fun <reified T> isType(value: Any): Boolean {
return value is T
}
println(isType<String>("hello")) // true
println(isType<Int>("hello")) // false
The reified keyword keeps the type information available at runtime by inlining the function body.
Practice Problems
Implement a generic Stack<T> class with push(item: T), pop(): T?, and peek(): T? operations. Use a mutableList as the backing store.
Solution
class Stack<T> {
private val elements = mutableListOf<T>()
fun push(item: T) {
elements.add(item)
}
fun pop(): T? {
if (elements.isEmpty()) return null
return elements.removeAt(elements.lastIndex)
}
fun peek(): T? {
return elements.lastOrNull()
}
fun isEmpty(): Boolean = elements.isEmpty()
fun size(): Int = elements.size
}
fun main() {
val stack = Stack<Int>()
stack.push(1)
stack.push(2)
stack.push(3)
println(stack.pop()) // 3
println(stack.peek()) // 2
println(stack.size()) // 2
} Write a generic function filterByType<T> that takes a List<Any> and returns a List<T> containing only elements of type T.
Solution
inline fun <reified T> filterByType(list: List<Any>): List<T> {
return list.filterIsInstance<T>()
}
fun main() {
val mixed = listOf(1, "hello", 2, "world", 3.14)
val strings = filterByType<String>(mixed)
println(strings) // [hello, world]
val ints = filterByType<Int>(mixed)
println(ints) // [1, 2]
} Create an interface Transformer<in T> with a transform(value: T): String method. Implement it for Transformer<Any> and pass it where Transformer<String> is expected.
Solution
interface Transformer<in T> {
fun transform(value: T): String
}
val anyTransformer = object : Transformer<Any> {
override fun transform(value: Any): String {
return "Value: $value (${value::class.simpleName})"
}
}
fun processStrings(transformer: Transformer<String>) {
println(transformer.transform("hello"))
println(transformer.transform("world"))
}
fun main() {
// Transformer<Any> can be used where Transformer<String> is expected
processStrings(anyTransformer)
// Value: hello (String)
// Value: world (String)
} Quiz
1. What does the 'out' keyword mean in a generic type parameter?
2. Why are generics erased at runtime in Kotlin?
3. What is the upper bound of a generic type parameter by default?
4. When should you use reified type parameters?
Flashcards
Question
What is the difference between in and out on generic type parameters?
Click to reveal answer
Answer
out (covariance) means the type only appears in output positions. The generic becomes a producer. in (contravariance) means the type only appears in input positions. The generic becomes a consumer.
Question
What is type erasure in Kotlin?
Click to reveal answer
Answer
At runtime, generic type information is removed by the JVM. You cannot check T is String at runtime unless you use reified type parameters in inline functions.
Question
How do you constrain a generic type parameter?
Click to reveal answer
Answer
Use an upper bound: class Box<T : String> means T must be String or subtype. Use where for multiple bounds: fun <T> f() where T : A, T : B.
Question
What does star projection (*) mean in Kotlin generics?
Click to reveal answer
Answer
Star projection is similar to Java wildcards. Use it when you do not care about the specific type. Example: List<*> accepts any List regardless of its type parameter.
Revision Notes
Key Takeaways
- 1. Generics provide type safety while allowing code reuse across types.
- 2. out makes a type covariant (producer); in makes it contravariant (consumer).
- 3. Type erasure removes generic type info at runtime; reified preserves it for inline functions.
- 4. Upper bounds restrict which types can be used as type parameters.
- 5. Star projection (*) is Kotlin's equivalent of Java wildcards.
Interview Tips
- • Explain variance with concrete examples: why Box<Dog> is not Box<Animal> by default.
- • Know the difference between in (contravariance) and out (covariance) with real-world analogies.
- • Understand type erasure and when reified types are needed.
- • Be ready to implement a generic data structure like Stack or Queue.
Cheat Sheet
Generics Cheat Sheet
Generic Class:
class Box<T>(val value: T)- Type parameter T can be any type
Generic Function:
fun <T> item(value: T): T = value- Declare before function name
Upper Bound:
class Box<T : Comparable<T>>- T must implement Comparable
- Multiple bounds:
where T : A, T : B
Variance:
out T— covariant (producer, output only)in T— contravariant (consumer, input only)- Default — invariant (neither)
Star Projection:
List<*>— type-agnostic usage- Cannot add elements (type unknown)
Reified Types:
inline fun <reified T> check(v: Any) = v is T- Preserves type info at runtime