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 — in and out
  • 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

ConceptSyntaxExample
Interfaceinterface Nameinterface Drawable
Generic classclass Name<T>class Box<T>(val value: T)
Generic functionfun <T> name()fun <T> listOf(item: T)
Type constraint<T : Type><T : Comparable<T>>
Multiple constraintswhere 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<*>
Reifiedreified Tinline 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:

KT-13 Source Code 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.