Skip to main content

Kotlin Type Constraints

Introduction

When working with generics in Kotlin, there are times when you need to restrict the types that can be used as type arguments. For instance, you might want to create a function that works only with numbers, or a class that requires its type parameter to implement a specific interface. This is where type constraints come into play.

Type constraints allow you to specify requirements on the types that can be used with your generic classes and functions. They are a powerful way to make your code more type-safe while still maintaining the flexibility that generics offer.

In this tutorial, we'll explore various ways to apply type constraints in Kotlin and see how they can improve your code.

Upper Bounds

The most common form of type constraint is an upper bound, which specifies that a type parameter must be a subtype of a specific type.

Basic Syntax

An upper bound is defined using the : symbol after the type parameter:

kotlin
fun <T : UpperBoundType> functionName(param: T): ReturnType {
// Function body
}

Example with Numbers

Let's create a function that calculates the average of a list, but only works with numbers:

kotlin
fun <T : Number> calculateAverage(list: List<T>): Double {
var sum = 0.0
for (item in list) {
sum += item.toDouble()
}
return if (list.isNotEmpty()) sum / list.size else 0.0
}

When we use this function:

kotlin
fun main() {
val intList = listOf(1, 2, 3, 4, 5)
val doubleList = listOf(1.5, 2.5, 3.5)
val stringList = listOf("one", "two", "three")

println("Average of integers: ${calculateAverage(intList)}")
println("Average of doubles: ${calculateAverage(doubleList)}")
// The following line would not compile:
// println("This won't work: ${calculateAverage(stringList)}")
}

Output:

Average of integers: 3.0
Average of doubles: 2.5

The compiler prevents us from passing a list of strings because String is not a subtype of Number.

With Interfaces

Upper bounds can also be interfaces. Let's create a function that only works with Comparable objects:

kotlin
fun <T : Comparable<T>> findMaxValue(list: List<T>): T? {
if (list.isEmpty()) return null
var max = list[0]
for (item in list) {
if (item > max) {
max = item
}
}
return max
}

Usage example:

kotlin
fun main() {
val numbers = listOf(5, 3, 9, 2, 8)
val strings = listOf("apple", "banana", "orange")

println("Max number: ${findMaxValue(numbers)}")
println("Max string: ${findMaxValue(strings)}")
}

Output:

Max number: 9
Max string: orange

Multiple Constraints

You can specify multiple constraints for a type parameter using the where keyword:

kotlin
fun <T> functionName(param: T) where T : Interface1, T : Interface2 {
// Function body
}

Let's create an example with multiple constraints:

kotlin
interface Printable {
fun print()
}

interface Drawable {
fun draw()
}

fun <T> renderToScreen(item: T) where T : Printable, T : Drawable {
item.print()
item.draw()
}

// A class that implements both interfaces
class Document : Printable, Drawable {
override fun print() {
println("Printing document...")
}

override fun draw() {
println("Drawing document on screen...")
}
}

Usage:

kotlin
fun main() {
val doc = Document()
renderToScreen(doc)
}

Output:

Printing document...
Drawing document on screen...

Type Constraints with Classes

Type constraints are commonly used with generic classes:

kotlin
class Repository<T : Entity> {
fun save(item: T) {
// Save to database
}

fun findById(id: Long): T? {
// Find item by ID
return null
}
}

Here's a more complete example:

kotlin
abstract class Entity {
abstract val id: Long
abstract fun validate(): Boolean
}

class User(override val id: Long, val name: String, val email: String) : Entity() {
override fun validate(): Boolean {
return name.isNotEmpty() && email.contains("@")
}
}

class Repository<T : Entity> {
private val items = mutableMapOf<Long, T>()

fun save(item: T): Boolean {
return if (item.validate()) {
items[item.id] = item
true
} else {
false
}
}

fun findById(id: Long): T? {
return items[id]
}
}

Usage:

kotlin
fun main() {
val userRepo = Repository<User>()

val validUser = User(1, "John", "[email protected]")
val invalidUser = User(2, "", "invalid-email")

println("Save valid user: ${userRepo.save(validUser)}")
println("Save invalid user: ${userRepo.save(invalidUser)}")

val retrievedUser = userRepo.findById(1)
println("Retrieved user: ${retrievedUser?.name}")
}

Output:

Save valid user: true
Save invalid user: false
Retrieved user: John

Type Projection with Constraints

You can combine type constraints with type projections (variance modifiers):

kotlin
fun <T> copyFromTo(source: List<out T>, destination: MutableList<in T>) {
for (item in source) {
destination.add(item)
}
}

Here's an example with a hierarchy of types:

kotlin
open class Animal {
open fun makeSound() {
println("Animal makes a sound")
}
}

class Cat : Animal() {
override fun makeSound() {
println("Meow")
}
}

class Dog : Animal() {
override fun makeSound() {
println("Woof")
}
}

fun <T : Animal> makeAllSound(animals: List<T>) {
for (animal in animals) {
animal.makeSound()
}
}

Usage:

kotlin
fun main() {
val cats = listOf(Cat(), Cat())
val dogs = listOf(Dog(), Dog())
val animals = mutableListOf<Animal>()

// This works because Cat is a subtype of Animal
makeAllSound(cats)

// This works because Dog is a subtype of Animal
makeAllSound(dogs)

// Adding both cats and dogs to the animals list
animals.addAll(cats)
animals.addAll(dogs)

// This works with the mixed list as well
makeAllSound(animals)
}

Output:

Meow
Meow
Woof
Woof
Meow
Meow
Woof
Woof

Real-world Application: Cache System

Let's create a more practical example: a generic cache system that requires items to be serializable:

kotlin
import java.io.Serializable

class Cache<T : Serializable> {
private val items = mutableMapOf<String, T>()

fun put(key: String, item: T) {
items[key] = item
}

fun get(key: String): T? {
return items[key]
}

fun remove(key: String) {
items.remove(key)
}

fun clear() {
items.clear()
}
}

Using the cache:

kotlin
data class UserProfile(val id: Long, val name: String, val email: String) : Serializable

fun main() {
val userCache = Cache<UserProfile>()

userCache.put("user1", UserProfile(1, "Alice", "[email protected]"))
userCache.put("user2", UserProfile(2, "Bob", "[email protected]"))

val user1 = userCache.get("user1")
println("Retrieved user: ${user1?.name}")

userCache.remove("user2")
println("After removal, user2 exists: ${userCache.get("user2") != null}")

userCache.clear()
println("After clear, user1 exists: ${userCache.get("user1") != null}")
}

Output:

Retrieved user: Alice
After removal, user2 exists: false
After clear, user1 exists: false

Summary

Type constraints in Kotlin generics allow you to restrict the types that can be used as type arguments, making your generic code more specific and safer. We've covered:

  • Upper bounds to specify that a type parameter must be a subtype of a specific type
  • Multiple constraints using the where keyword
  • Type constraints with generic classes
  • Combining type constraints with type projections
  • A real-world example of a cache system that requires serializable objects

Type constraints help you leverage the type system to write more robust code while still maintaining the flexibility and reusability benefits of generics.

Exercises

  1. Create a generic SortedList<T> class that only accepts Comparable items.
  2. Implement a generic Validator<T> interface with a validate(item: T): Boolean method, and create implementations for validating strings, numbers, and custom objects.
  3. Design a generic EventHandler<T : Event> class where Event is an interface with a process() method.
  4. Create a generic function that finds the largest element in a collection, but only works with collections of numeric types.
  5. Implement a generic JsonSerializer<T> class that requires its type to be annotated with a custom @Serializable annotation.

Additional Resources



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