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:
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:
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:
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:
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:
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:
fun <T> functionName(param: T) where T : Interface1, T : Interface2 {
// Function body
}
Let's create an example with multiple constraints:
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:
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:
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:
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:
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):
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:
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:
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:
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:
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
- Create a generic
SortedList<T>
class that only acceptsComparable
items. - Implement a generic
Validator<T>
interface with avalidate(item: T): Boolean
method, and create implementations for validating strings, numbers, and custom objects. - Design a generic
EventHandler<T : Event>
class whereEvent
is an interface with aprocess()
method. - Create a generic function that finds the largest element in a collection, but only works with collections of numeric types.
- 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! :)