Overview
Type parameter constraints allow you to limit the types that can be accepted as a type argument to an upper bound, giving you the ability to invoke functions and properties on an object while preserving the type.
If that sounds confusing, go check out the guide, When to Use Type Parameter Constraints, which walks through the use cases in more detail.
In this article, we’re going to look at the variety of ways that you can create a type parameter constraint in Kotlin. Type parameter declarations can go on interfaces, classes, functions, and extension properties, but since functions demonstrate things so well, we’ll use them for our examples.
Examples: The Concise Way
Single Type Parameter
The easiest way to constrain a type parameter is to name a type in the type parameter declaration. For example, we’ll put : Pet
inside our declaration here:
fun <T : Pet> chooseFavorite(pets: List<T>): T {
val favorite = pets[random.nextInt(pets.size)]
println("${favorite.name} is the favorite")
return favorite
}
In this case, the code that calls chooseFavorite()
can supply a List
of anything that’s of type Pet
, but it couldn’t, for example, supply a List<String>
or List<Int>
:
val cats: List<Cat> = listOf(Cat("Whiskers"), Cat("Rosie"))
val favorite: Cat = chooseFavorite(cats)
Multiple Type Parameters
You can specify multiple type parameters by separating them with a comma, and each one can have a constraint:
fun <T : Pet, U : Human> chooseFavorite(pets: List<T>, owners: List<U>): T {
val owner = owners[random.nextInt(owners.size)]
val favorite = pets[random.nextInt(pets.size)]
println("${favorite.name} is ${owner.name}'s favorite")
return favorite
}
In this example, the chooseFavorite()
function declares two type parameters - T
and U
, which means:
pets
can be aList
of anythingPet
or subtype ofPet
(as above), and…owners
can be aList
of anythingHuman
or subtype ofHuman
Examples: The Verbose Way
Single Type Parameters
Alternatively, you can put the constraint after the return type, following the keyword where
. Let’s take our first example and rework it to this more verbose syntax.
fun <T> chooseFavorite(pets: List<T>): T where T : Pet {
val favorite = pets[random.nextInt(pets.size)]
println("${favorite.name} is the favorite")
return favorite
}
The where T : Pet
indicates the same thing as <T : Pet>
did in the first example.
Multiple Type Parameters
As you’d expect, you also can specify multiple type parameter constraints using the where
syntax:
fun <T, U> chooseFavorite(pets: List<T>, owners: List<U>): T where T : Pet, U : Human {
val owner = owners[random.nextInt(owners.size)]
val favorite = pets[random.nextInt(pets.size)]
println("${favorite.name} is ${owner.name}'s favorite")
return favorite
}
Well, if you can achieve the same thing with both a concise syntax and a more verbose syntax, why would anyone choose the more verbose?
Multiple Constraints on One Parameter
The answer, as you probably guessed, is that it lets you do more: it lets you specify multiple constraints on a single parameter.
So, imagine that we don’t have a Pet
type, but instead we have an Animal
interface (that has a species
property) and a Named
interface (that has a name
property). In that case, any class that implements both of these interfaces can fit the definition of a pet. Here’s how you’d write the constraints.
fun <T> chooseFavorite(pets: List<T>): T where T : Animal, T : Named {
val favorite = pets[random.nextInt(pets.size)]
println("${favorite.name} is the favorite ${favorite.species}")
return favorite
}
(Fascinating side note: the compiled bytecode effectively uses the first type constraint as the return type - in this case, Animal
. The return type doesn’t really matter, though, because it’ll add casts everywhere that it’s needed.)
Putting Constraints in Both Places
Ready to live life on the edge?! Hold onto your cargo pockets - we’re gonna get dangerous here…
We’re going to split our constraints! Let’s put Animal
within the angle brackets and Named
in the where
clause:
fun <T : Animal> chooseFavorite(pets: List<T>): T where T : Named { /* ... */ }
This fails inspection in the IDE with this message:
“If a type parameter has multiple constraints, they all need to be placed in the ‘where’ clause” (Source)
However, this still compiled and executed for me. So, you technically can split multiple constraints for the same parameter by putting the first within the <T>
declaration and the rest in the where
clause.
But c’mon, now… you’re a better programmer than that…
Default Constraint
If you don’t specify a constraint, the default constraint will be Any?
.
fun <T> chooseFavorite(things: List<T>): T { /* ... */ }
is the exactly the same as
fun <T : Any?> chooseFavorite(things: List<T>): T { /* ... */ }
Nulls
If you want to specify a nullable type for the constraint, it’ll work as expected:
fun <T : Pet?> chooseFavorite(pets: List<T>): T {
val favorite = pets[random.nextInt(pets.size)]
println("${favorite?.name ?: "Nobody"} is the favorite")
return favorite
}
val maybeCats: List<Cat?> = listOf(Cat("Whiskers"), null, Cat("Rosie"))
val favorite: Cat? = chooseFavorite(maybeCats)
Just remember that the ?
goes on the type constraint, not the type parameter references. In other words, in this example, you wouldn’t specify List<T?>
or return T?
.
Any Non-Null
Naturally, if you want to accept any type that isn’t null, you’d just use Any
as the constraint:
fun <T : Any> chooseFavorite(things: List<T>): T { /* ... */ }
Recommendations
I’ve not seen much talk in the Kotlin community about best practices for type parameter constraints, but here are some common-sense guidelines.
- Favor the concise syntax unless you need to specify multiple constraints on a single type parameter.
- When using a
where
clause, put all of the constraints there. - Don’t specify a constraint of
Any?
since that’s the default.
Check out the guide, When to Use Type Parameter Constraints, for additional examples and explanations.