Skip to main content

Kotlin Generic Constraints

In our journey through Kotlin generics, we've learned how generics provide flexibility by allowing us to write code that works with multiple types. However, there are scenarios where we need to restrict what types can be used with our generic classes or functions. This is where generic constraints come into play.

Understanding Generic Constraints

Generic constraints (also known as bounded types) allow us to limit what types can be used as type arguments in our generic classes and functions. Instead of accepting absolutely any type, we can specify that only types with certain characteristics are allowed.

Think of constraints as setting requirements for your generic types, similar to how job postings list required qualifications for candidates.

Why Use Generic Constraints?

Before diving into how constraints work, let's understand why they're useful:

  1. Type Safety: Constraints ensure you only work with types that have the capabilities you need
  2. Access to Specific Methods: By constraining to a certain type, you can access methods specific to that type
  3. Semantic Correctness: They help express design intent clearly
  4. Avoid Runtime Errors: They catch type mismatches at compile time

Upper Bounds: The Basic Constraint

The most common form of constraint is the upper bound, which specifies that a type parameter must be or extend a particular type.

Syntax

kotlin
class MyClass<T : UpperBoundType> {
// Implementation
}

fun <T : UpperBoundType> myFunction(item: T) {
// Implementation
}

The : UpperBoundType part is what sets the constraint, requiring T to be either UpperBoundType or a subtype of it.

Example: A Number Processor

Let's create a class that works only with numbers:

kotlin
class NumberProcessor<T : Number> {
fun process(value: T): Double {
return value.toDouble() * 2
}
}

fun main() {
// Works with various number types
val intProcessor = NumberProcessor<Int>()
println(intProcessor.process(5)) // Output: 10.0

val doubleProcessor = NumberProcessor<Double>()
println(doubleProcessor.process(5.5)) // Output: 11.0

// This would cause a compilation error:
// val stringProcessor = NumberProcessor<String>()
}

In this example, NumberProcessor only accepts types that inherit from Number, which includes Int, Double, Float, etc. Trying to use it with String would result in a compilation error.

Multiple Constraints with Where Clause

Sometimes a single constraint isn't enough. Kotlin allows us to specify multiple constraints using the where clause:

kotlin
fun <T> someFunction(item: T) where T : Interface1, T : Interface2 {
// Implementation using methods from both interfaces
}

Example: Comparable and Printable Items

Let's say we have two interfaces:

kotlin
interface Printable {
fun prettyPrint(): String
}

// Let's create a class that implements both Comparable and our custom Printable interface
class Person(val name: String, val age: Int) : Comparable<Person>, Printable {
override fun compareTo(other: Person): Int {
return this.age - other.age
}

override fun prettyPrint(): String {
return "Person(name=$name, age=$age)"
}
}

// Function that requires both constraints
fun <T> findAndPrintMax(list: List<T>): T? where T : Comparable<T>, T : Printable {
if (list.isEmpty()) return null

val max = list.maxOrNull()
println("Max element: ${max?.prettyPrint()}")
return max
}

fun main() {
val people = listOf(
Person("Alice", 29),
Person("Bob", 31),
Person("Charlie", 25)
)

val oldest = findAndPrintMax(people)
// Output: Max element: Person(name=Bob, age=31)
}

In this example, our findAndPrintMax function requires that the type T implements both Comparable<T> and Printable. This allows us to both find the maximum element and pretty-print it.

Type Constraints for Extension Functions

Constraints are particularly useful for extension functions, allowing you to add functionality to specific types:

kotlin
fun <T : Comparable<T>> List<T>.findSorted(): List<T> {
return this.sorted()
}

fun main() {
val names = listOf("Charlie", "Alice", "Bob")
println(names.findSorted()) // Output: [Alice, Bob, Charlie]

val numbers = listOf(5, 2, 8, 1)
println(numbers.findSorted()) // Output: [1, 2, 5, 8]

// This would work with any list of Comparable items
}

Nullable Type Constraints

You can also specify whether a generic type can be nullable:

kotlin
// T can be any type, including nullable types
class Box<T>(var value: T)

// T must be non-null
class StrictBox<T : Any>(var value: T)

fun main() {
val box = Box<String?>(null) // OK

// This would cause a compilation error:
// val strictBox = StrictBox<String?>(null)

// But this is OK:
val strictBox = StrictBox<String>("Hello")
}

In StrictBox, the constraint T : Any specifies that T must be a non-nullable type.

Real-World Example: A Repository Pattern

Let's see how we might use generic constraints in a real-world scenario by implementing a simple repository pattern:

kotlin
// A base entity interface
interface Entity {
val id: Long
}

// A generic repository interface
interface Repository<T : Entity> {
fun findById(id: Long): T?
fun save(entity: T): T
fun delete(entity: T)
fun findAll(): List<T>
}

// An implementation of our entity
class User(override val id: Long, val name: String, val email: String) : Entity

// Repository implementation for Users
class UserRepository : Repository<User> {
private val storage = mutableMapOf<Long, User>()

override fun findById(id: Long): User? {
return storage[id]
}

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

override fun delete(entity: User) {
storage.remove(entity.id)
}

override fun findAll(): List<User> {
return storage.values.toList()
}
}

fun main() {
val userRepo = UserRepository()

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

// Find and print a user
val user = userRepo.findById(1)
println("Found user: ${user?.name}, ${user?.email}")
// Output: Found user: Alice, [email protected]

// List all users
val allUsers = userRepo.findAll()
println("All users:")
allUsers.forEach { println("- ${it.name} (${it.email})") }
/* Output:
All users:
- Alice ([email protected])
- Bob ([email protected])
*/
}

In this example:

  1. We defined an Entity interface with an id property
  2. Our Repository interface uses the constraint T : Entity to ensure it only works with entity types
  3. The UserRepository implementation is type-safe and specific to the User entity type

Reified Type Parameters with Constraints

When using reified type parameters (in inline functions), you can also apply constraints:

kotlin
inline fun <reified T : Number> List<Any>.filterIsInstance(): List<T> {
val result = mutableListOf<T>()
for (item in this) {
if (item is T) {
result.add(item)
}
}
return result
}

fun main() {
val mixedList = listOf(1, "hello", 2.5, "world", 3f)

val numbers = mixedList.filterIsInstance<Number>()
println(numbers) // Output: [1, 2.5, 3.0]

val integers = mixedList.filterIsInstance<Int>()
println(integers) // Output: [1]
}

This function filters a list to only include items of a specific numeric type.

Summary

Generic constraints in Kotlin add an important layer of specificity to your generic code:

  • Upper bounds (T : SomeType) restrict generic types to a specific class or its subtypes
  • Multiple constraints using where clauses allow you to require that types implement multiple interfaces
  • Constraints give you access to methods and properties of the bounding type
  • You can use constraints with classes, functions, and extension functions
  • Constraints help catch errors at compile-time rather than runtime

Generic constraints strike a balance between the flexibility of generics and the safety of specific types. They allow you to write code that works with a range of types while still ensuring those types have the capabilities your code needs.

Exercises

  1. Create a SortedList<T> class that only accepts types that implement Comparable<T>
  2. Write a function <T : Number> average(list: List<T>) that calculates the average of a list of numbers
  3. Implement a generic Validator<T> interface with a validate method, then create specific validators for String and Int
  4. Design a JsonSerializable interface and write a function that only accepts types implementing this interface
  5. Create a generic Cache<K : Any, V> class where the key type must be non-nullable

Additional Resources



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