Skip to main content

Kotlin Generics Basics

Introduction

Generics are a powerful feature in Kotlin that allow you to write flexible, reusable, and type-safe code. With generics, you can create classes, interfaces, and functions that work with different types while maintaining compile-time type safety. If you've ever used collections like List<String> or Map<String, User>, you've already encountered generics in action.

In this tutorial, we'll explore the fundamentals of Kotlin generics, understand why they're essential, and learn how to implement them in your code.

Why Use Generics?

Before diving into generics, let's understand why they're valuable:

  1. Type Safety: Generics catch type errors at compile-time rather than runtime.
  2. Code Reusability: Write a single class or function that works with different types.
  3. Eliminate Type Casting: Avoid explicit casting, reducing the risk of ClassCastException.

Let's see an example of code without generics and the issues it can cause:

kotlin
// Without generics - limited to storing only one type (Any)
class Box {
private var item: Any? = null

fun setItem(item: Any) {
this.item = item
}

fun getItem(): Any? {
return item
}
}

fun main() {
val box = Box()
box.setItem("Hello, World!")

// Problem: Need to cast to the expected type
val message = box.getItem() as String
println(message)

// Danger: Runtime error if type is incorrect!
box.setItem(42)
val anotherMessage = box.getItem() as String // ClassCastException!
}

Output:

Hello, World!
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

Creating Generic Classes

Now, let's create the same box example using generics:

kotlin
// With generics - type safe!
class Box<T> {
private var item: T? = null

fun setItem(item: T) {
this.item = item
}

fun getItem(): T? {
return item
}
}

fun main() {
// String box - only accepts strings
val stringBox = Box<String>()
stringBox.setItem("Hello, World!")
println(stringBox.getItem())

// Won't compile: Type mismatch
// stringBox.setItem(42)

// Integer box - only accepts integers
val intBox = Box<Int>()
intBox.setItem(42)
println(intBox.getItem())
}

Output:

Hello, World!
42

In the generic version, we use T as a type parameter. The compiler ensures type safety by enforcing that only values of the specified type can be stored in the box.

Type Parameter Naming Conventions

In Kotlin, it's conventional to use single uppercase letters for type parameters:

  • T: Type (general purpose)
  • E: Element (often used with collections)
  • K: Key (used in maps)
  • V: Value (used in maps)
  • N: Number

Multiple Type Parameters

You can define classes with multiple type parameters:

kotlin
class Pair<A, B>(val first: A, val second: B) {
override fun toString(): String = "($first, $second)"
}

fun main() {
val namePair = Pair("John", "Doe")
val nameAndAge = Pair("Alice", 30)
val coordinates = Pair(10.5, 20.3)

println(namePair) // (John, Doe)
println(nameAndAge) // (Alice, 30)
println(coordinates) // (10.5, 20.3)
}

Output:

(John, Doe)
(Alice, 30)
(10.5, 20.3)

Generic Functions

In addition to classes, you can create generic functions:

kotlin
fun <T> printItem(item: T) {
println("Item: $item")
}

fun <T> List<T>.firstOrNull(predicate: (T) -> Boolean): T? {
for (element in this) {
if (predicate(element)) {
return element
}
}
return null
}

fun main() {
printItem("Hello")
printItem(42)
printItem(3.14)

val names = listOf("Alice", "Bob", "Charlie", "David")
val result = names.firstOrNull { it.length > 5 }
println("First name with length > 5: $result")
}

Output:

Item: Hello
Item: 42
Item: 3.14
First name with length > 5: Charlie

Type Inference with Generics

Kotlin's type inference works with generics, so you often don't need to explicitly specify the type parameters:

kotlin
fun main() {
// Type is inferred as Box<String>
val box = Box<String>()

// Or even simpler:
val anotherBox = Box<String>().apply {
setItem("Kotlin Generics")
}

println(anotherBox.getItem())
}

Output:

Kotlin Generics

Generic Constraints

You can restrict the types that can be used with your generic class or function using type constraints:

kotlin
// T must be a subtype of Number
class NumberBox<T : Number> {
private var value: T? = null

fun setValue(value: T) {
this.value = value
}

fun getValue(): T? = value

fun displayType() {
println("Value type: ${value?.javaClass?.simpleName}")
}
}

fun main() {
val intBox = NumberBox<Int>().apply {
setValue(10)
}

val doubleBox = NumberBox<Double>().apply {
setValue(3.14)
}

intBox.displayType()
doubleBox.displayType()

// Won't compile: Type argument is not within bounds
// val stringBox = NumberBox<String>()
}

Output:

Value type: Integer
Value type: Double

Real-world Example: Repository Pattern

Let's implement a simplified version of the repository pattern using generics:

kotlin
// Define a base entity
interface Entity {
val id: Int
}

// Sample entity implementations
data class User(override val id: Int, val name: String, val email: String) : Entity
data class Product(override val id: Int, val name: String, val price: Double) : Entity

// Generic repository
interface Repository<T : Entity> {
fun getById(id: Int): T?
fun getAll(): List<T>
fun save(entity: T): T
fun delete(id: Int): Boolean
}

// Implementation for User entity
class UserRepository : Repository<User> {
private val users = mutableMapOf<Int, User>()

override fun getById(id: Int): User? = users[id]

override fun getAll(): List<User> = users.values.toList()

override fun save(entity: User): User {
users[entity.id] = entity
return entity
}

override fun delete(id: Int): Boolean {
return users.remove(id) != null
}
}

fun main() {
val userRepo = UserRepository()

// Add some users
userRepo.save(User(1, "Alice", "[email protected]"))
userRepo.save(User(2, "Bob", "[email protected]"))

// Get all users
val users = userRepo.getAll()
println("All users:")
users.forEach { println("${it.id}: ${it.name} (${it.email})") }

// Get user by ID
val user = userRepo.getById(1)
println("\nUser with ID 1: ${user?.name ?: "Not found"}")

// Delete user
val deleted = userRepo.delete(2)
println("\nDeleted user with ID 2: $deleted")

// Get all users after deletion
println("\nRemaining users:")
userRepo.getAll().forEach { println("${it.id}: ${it.name}") }
}

Output:

All users:
1: Alice ([email protected])
2: Bob ([email protected])

User with ID 1: Alice

Deleted user with ID 2: true

Remaining users:
1: Alice

This example demonstrates how generics allow us to create a reusable repository pattern that can work with any entity type, while maintaining type safety.

Summary

In this tutorial, you've learned:

  • The basic concepts of generics in Kotlin
  • How to create generic classes with type parameters
  • How to define and use generic functions
  • How to apply constraints to type parameters
  • A practical example using the repository pattern

Generics are a powerful tool that help you write more flexible, reusable, and type-safe code. They're essential for creating robust libraries and frameworks, and they can greatly improve the quality and maintainability of your code.

Additional Resources

Exercises

  1. Create a generic Stack<T> class with push(), pop(), and peek() methods.
  2. Implement a generic Result<T> class that can represent either a successful result with a value of type T or an error with an error message.
  3. Create a generic Cache<K, V> class that stores key-value pairs with a maximum size limit.
  4. Write a generic extension function <T> List<T>.findDuplicates(): List<T> that returns all duplicated elements in the list.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)