In the previous tutorial, you learned about sealed classes, enum classes, and value classes. Now let’s learn about interfaces, generics, and type constraints. These features let you write flexible, reusable code while keeping type safety.
In this tutorial, you will learn:
- Interfaces and multiple inheritance
- Generics with
<T> - Type constraints with
where - Variance —
inandout - Star projection
* - Reified type parameters
Interfaces
An interface defines a contract. Classes that implement the interface must provide the required functions and properties.
Basic Interface
interface Drawable {
fun draw(): String
}
interface Resizable {
fun resize(factor: Double): String
}
Default Implementations
Interfaces can have default implementations:
interface Loggable {
val tag: String
fun log(message: String): String {
return "[$tag] $message"
}
}
Multiple Inheritance
A class can implement multiple interfaces:
class Widget(override val tag: String, val name: String)
: Drawable, Resizable, Loggable {
override fun draw(): String = "Drawing $name"
override fun resize(factor: Double): String = "Resizing $name by ${factor}x"
}
val widget = Widget("UI", "Button")
println(widget.draw()) // Drawing Button
println(widget.resize(2.0)) // Resizing Button by 2.0x
println(widget.log("Created")) // [UI] Created
Conflict Resolution
When two interfaces have the same method, you must override it and specify which one to call:
interface A {
fun greet(): String = "Hello from A"
}
interface B {
fun greet(): String = "Hello from B"
}
class C : A, B {
override fun greet(): String {
return "${super<A>.greet()} and ${super<B>.greet()}"
}
}
println(C().greet()) // Hello from A and Hello from B
Use super<InterfaceName> to call a specific interface’s default implementation.
Generics
Generics let you write code that works with any type. You use a type parameter <T> that gets replaced with a real type when you use the class or function.
Generic Class
class Box<T>(val value: T) {
fun get(): T = value
override fun toString(): String = "Box($value)"
}
val intBox = Box(42)
val stringBox = Box("Hello")
val listBox = Box(listOf(1, 2, 3))
println(intBox) // Box(42)
println(stringBox) // Box(Hello)
println(listBox) // Box([1, 2, 3])
Multiple Type Parameters
class Pair<A, B>(val first: A, val second: B) {
override fun toString(): String = "($first, $second)"
}
val pair = Pair("Alex", 25)
println(pair) // (Alex, 25)
Generic Functions
fun <T> singletonList(item: T): List<T> {
return listOf(item)
}
fun <T, R> transform(value: T, transformer: (T) -> R): R {
return transformer(value)
}
println(singletonList("Hello")) // [Hello]
println(transform(42) { it.toString() }) // 42
The type is inferred from the arguments. You do not need to specify it.
Type Constraints
Type constraints restrict which types can be used as type parameters.
Single Constraint
Use <T : SomeType> to require that T is a subtype:
fun <T : Comparable<T>> findMax(a: T, b: T): T {
return if (a > b) a else b
}
println(findMax(10, 20)) // 20
println(findMax("apple", "banana")) // banana
println(findMax(3.14, 2.71)) // 3.14
This works with any Comparable type — Int, String, Double, etc.
fun <T : Number> doubleValue(value: T): Double {
return value.toDouble() * 2
}
println(doubleValue(5)) // 10.0
println(doubleValue(3.5f)) // 7.0
Multiple Constraints with where
Use where when you need multiple constraints:
fun <T> printSorted(list: List<T>) where T : Comparable<T>, T : Any {
println("Sorted: ${list.sorted()}")
}
printSorted(listOf(3, 1, 4, 1, 5, 9))
// Sorted: [1, 1, 3, 4, 5, 9]
printSorted(listOf("cherry", "apple", "banana"))
// Sorted: [apple, banana, cherry]
Generic Class with Constraint
class SortedList<T : Comparable<T>> {
private val items = mutableListOf<T>()
fun add(item: T) {
items.add(item)
items.sort()
}
fun getAll(): List<T> = items.toList()
}
val numbers = SortedList<Int>()
numbers.add(5)
numbers.add(2)
numbers.add(8)
numbers.add(1)
println(numbers.getAll()) // [1, 2, 5, 8]
Variance: in and out
Variance controls how generic types relate to each other in the type hierarchy.
out (Covariant) — Producer
out means the type parameter is only used as output (produced, not consumed). If Dog is a subtype of Animal, then Producer<Dog> is a subtype of Producer<Animal>.
interface Producer<out T> {
fun produce(): T
}
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class DogProducer : Producer<Dog> {
override fun produce(): Dog = Dog("Buddy")
}
// This works because of `out`
val dogProducer: Producer<Dog> = DogProducer()
val animalProducer: Producer<Animal> = dogProducer
println(animalProducer.produce().name) // Buddy
Kotlin’s List is declared as List<out E>, which is why you can assign List<Dog> to List<Animal>:
val dogs: List<Dog> = listOf(Dog("Rex"), Dog("Buddy"))
val animals: List<Animal> = dogs // OK because List is covariant
in (Contravariant) — Consumer
in means the type parameter is only used as input (consumed, not produced). If Dog is a subtype of Animal, then Consumer<Animal> is a subtype of Consumer<Dog>.
interface Consumer<in T> {
fun consume(item: T)
}
class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
println("Consuming: ${item.name}")
}
}
// This works because of `in`
val animalConsumer: Consumer<Animal> = AnimalConsumer()
val dogConsumer: Consumer<Dog> = animalConsumer
dogConsumer.consume(Dog("Rex"))
Simple Rule
- out — the class only produces T (returns it)
- in — the class only consumes T (takes it as parameter)
Star Projection
Star projection * is used when you do not know or care about the type parameter. List<*> means “a list of some type, but I don’t know which.”
fun printList(list: List<*>) {
for (item in list) {
println(" Item: $item")
}
}
printList(listOf(1, 2, 3))
printList(listOf("A", "B", "C"))
Items from List<*> come back as Any?. You can check their type:
fun describeList(list: List<*>): String {
return when {
list.isEmpty() -> "Empty list"
list.first() is String -> "List of strings (${list.size} items)"
list.first() is Int -> "List of integers (${list.size} items)"
else -> "List of unknown type (${list.size} items)"
}
}
println(describeList(listOf(1, 2, 3))) // List of integers (3 items)
println(describeList(listOf("A", "B"))) // List of strings (2 items)
Reified Type Parameters
Normally, generic type information is erased at runtime (called type erasure). You cannot check value is T inside a generic function.
With reified and inline, you can keep type information at runtime:
inline fun <reified T> isType(value: Any): Boolean {
return value is T
}
println(isType<Int>(42)) // true
println(isType<String>(42)) // false
println(isType<String>("Hello")) // true
Filter a list by type:
inline fun <reified T> filterByType(list: List<Any>): List<T> {
return list.filterIsInstance<T>()
}
val mixed: List<Any> = listOf(1, "Hello", 2, "World", 3, true)
val strings = filterByType<String>(mixed)
val ints = filterByType<Int>(mixed)
println(strings) // [Hello, World]
println(ints) // [1, 2, 3]
Get the type name:
inline fun <reified T> typeName(): String {
return T::class.simpleName ?: "Unknown"
}
println(typeName<String>()) // String
println(typeName<Int>()) // Int
reified only works with inline functions. The compiler needs to inline the function to preserve the type information. We will explore reified and inline in much more depth in Tutorial #20: Inline and Reified.
Type Erasure
At runtime, generic type information is erased. This is called type erasure. The JVM does not know the difference between List<String> and List<Int> at runtime.
val strings: List<String> = listOf("Hello")
val ints: List<Int> = listOf(42)
// At runtime, both are just List
// You cannot do: if (strings is List<String>) — won't work reliably
This is why you cannot do is T checks in regular generic functions. The type T is not available at runtime.
The exception is reified type parameters with inline functions, which we covered above. The compiler inlines the function and replaces T with the actual type.
Generic Constraints in Practice
Here are some real-world examples of type constraints:
Sortable Collection
fun <T : Comparable<T>> List<T>.customSort(): List<T> {
return this.sortedBy { it }
}
println(listOf(3, 1, 2).customSort()) // [1, 2, 3]
println(listOf("c", "a", "b").customSort()) // [a, b, c]
Numeric Operations
fun <T : Number> average(vararg numbers: T): Double {
return numbers.sumOf { it.toDouble() } / numbers.size
}
println(average(1, 2, 3, 4, 5)) // 3.0
println(average(1.5, 2.5, 3.5)) // 2.5
Constrained Extension Function
fun <T> List<T>.maxOrNull(): T? where T : Comparable<T> {
if (isEmpty()) return null
var max = first()
for (item in this) {
if (item > max) max = item
}
return max
}
println(listOf(3, 1, 5, 2).maxOrNull()) // 5
println(listOf("c", "a", "b").maxOrNull()) // c
println(emptyList<Int>().maxOrNull()) // null
Practical Example: Generic Repository
Here is a complete example using interfaces and generics:
interface Repository<T> {
fun getById(id: Int): T?
fun getAll(): List<T>
fun save(item: T)
fun delete(id: Int)
}
data class Task(val id: Int, val title: String, val done: Boolean = false)
class InMemoryRepository<T> : Repository<T> {
private val items = mutableMapOf<Int, T>()
private var nextId = 1
override fun getById(id: Int): T? = items[id]
override fun getAll(): List<T> = items.values.toList()
override fun save(item: T) { items[nextId++] = item }
override fun delete(id: Int) { items.remove(id) }
}
val repo = InMemoryRepository<Task>()
repo.save(Task(1, "Learn Kotlin"))
repo.save(Task(2, "Write tests"))
println(repo.getAll()) // [Task(...), Task(...)]
println(repo.getById(1)) // Task(id=1, title=Learn Kotlin, done=false)
The same InMemoryRepository works with any type — Task, String, User, or anything else.
// Also works with strings
val stringRepo = InMemoryRepository<String>()
stringRepo.save("Hello")
stringRepo.save("World")
println(stringRepo.getAll()) // [Hello, World]
This is the power of generics. You write the logic once, and it works with any type. The compiler ensures type safety — you cannot accidentally store an Int in a Repository<String>.
Summary
| Concept | Syntax | Example |
|---|---|---|
| Interface | interface Name | interface Drawable |
| Generic class | class Name<T> | class Box<T>(val value: T) |
| Generic function | fun <T> name() | fun <T> listOf(item: T) |
| Type constraint | <T : Type> | <T : Comparable<T>> |
| Multiple constraints | where T : | where T : Comparable<T>, T : Any |
| Covariant (out) | <out T> | interface Producer<out T> |
| Contravariant (in) | <in T> | interface Consumer<in T> |
| Star projection | <*> | List<*> |
| Reified | reified T | inline fun <reified T> isType() |
Key rules:
- Use out when the class only produces T (like List)
- Use in when the class only consumes T (like Comparable)
- Use star projection when you don’t care about the type
- Use reified when you need runtime type info (requires inline)
Source Code
You can find the complete source code for this tutorial on GitHub:
What’s Next?
In this tutorial, you learned about interfaces, generics, type constraints, variance, star projection, and reified type parameters. These concepts are essential for writing flexible and type-safe Kotlin code.
In the next tutorial, you will learn about error handling — try/catch, the Result type, and error handling patterns.
This is part 13 of the Kotlin Tutorial series. Check out Part 12: Sealed Classes, Enum Classes, and Value Classes if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.