Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Designing Type-Safe Kotlin APIs with Generic Constraints and Variance

Welcome back. In the last lesson, matchingBookmarks accepted a function that worked with Article objects. That was convenient because the type was known. A shared feed API might also handle promotions or other feed items—but it should not accept any object, nor should it let callers put the wrong kind of item into a collection.

In about 40 minutes, we’ll use generic constraints to state what an item must provide, then use declaration-site variance to state whether an API produces or consumes that item. The goal is a Kotlin API whose permitted uses make sense at a senior-level code review.


Constrain a generic type to what the operation needs

Suppose several feed item types have an ID. We want to discard items whose IDs are blank while preserving each caller’s specific item type.

interface FeedItem {
    val id: String
}

data class Article(
    override val id: String,
    val title: String,
    val subtitle: String?
) : FeedItem

data class Promotion(
    override val id: String,
    val message: String
) : FeedItem

fun <T : FeedItem> withNonBlankIds(items: List<T>): List<T> =
    items.filter { it.id.isNotBlank() }

T : FeedItem is an upper bound: callers may supply Article, Promotion, or another subtype of FeedItem, but not an unrelated type. Inside the function, the compiler lets us use item.id because the bound guarantees that property exists.

The type parameter also preserves information that a less specific signature would lose:

val articles: List<Article> =
    listOf(Article("a1", "Kotlin state", null))

val validArticles: List<Article> = withNonBlankIds(articles)

If the function instead accepted and returned List<FeedItem>, the result would be known only as List<FeedItem>. Use a type parameter when retaining that relationship between input and output matters—not merely because a function could be generic.

Read the Kotlin documentation’s “Generic constraints” section, particularly “Upper bounds.” Follow its single-bound example through the where-clause example; the rendered code samples are useful alongside the prose.

Generics: in, out, where | Kotlin Documentation

Read the Kotlin documentation's explanation of generic constraints to see how a bound gives a generic function access to required operations.

Under “Generic constraints” and “Upper bounds,” begin with the bounds explanation. Continue through the paragraph after the where example. Focus on why the compiler can use members promised by a bound, and on the requirement that a type satisfy every bound in a where clause.

One bound is often enough. When an operation genuinely needs two independent capabilities, a where clause can express both. Here the result keeps its specific type and is nullable because there might be no item with the requested ID:

interface Updated {
    val updatedAtMillis: Long
}

fun <T> newestForId(items: List<T>, id: String): T?
    where T : FeedItem, T : Updated =
    items
        .filter { it.id == id }
        .maxByOrNull { it.updatedAtMillis }

An item must implement both FeedItem and Updated to be accepted. The bounds are about which types qualify; they do not yet say whether a container of articles can be used where a container of feed items is expected. That is the separate question variance answers.


Subtyping does not automatically carry over to containers

An Article is a FeedItem. Does that make a container of articles usable wherever a container of feed items is expected? It depends on what the container permits.

Consider a mutable container:

val articles: MutableList<Article> = mutableListOf(
    Article("a1", "Kotlin state", null)
)

// This does not compile:
val feedItems: MutableList<FeedItem> = articles

If the assignment were allowed, another caller could add a Promotion through feedItems. The original articles list would then contain something that is not an Article. Kotlin prevents this by making MutableList<T> invariant in T: MutableList<Article> is not a subtype of MutableList<FeedItem>.

A read-only List has a different relationship:

val articleList: List<Article> = articles
val feedItemList: List<FeedItem> = articleList // OK

A caller reading from feedItemList may expect any FeedItem; every Article meets that promise. Kotlin’s List is declared covariant in its element type. Read-only does not mean deeply immutable, however: another reference may still hold and mutate the underlying mutable list. Variance here protects the types available through the List API.

For an intuition about why output and input positions behave differently, watch these two portions of Dave Leeds’s “Variance... without Generics!”. The video begins with ordinary return types and parameters; we’ll apply that reasoning to generic interfaces next.

Variance... without Generics!

Dave Leeds uses a vending-machine interface to show why narrowing an output remains safe while narrowing an accepted input does not.

Watch the output case, focusing on why a machine promising a snack may safely return a more specific snack. Then watch the input case, focusing on why a machine expected to accept any coin cannot start accepting only quarters. The video discusses a Kotlin overriding limitation along the way; for this lesson, retain the type-safety reasoning rather than treating its workaround as an API pattern.


Put the promise on the API declaration

We can design two small boundaries for the feed feature. A reader provides items; a sink accepts them:

interface FeedReader<out T : FeedItem> {
    fun snapshot(): List<T>
}

interface FeedSink<in T : FeedItem> {
    fun accept(item: T)
}

Both type parameters are constrained to FeedItem. Their variance modifiers answer a different question:

  • out T is covariant. FeedReader produces T, so a FeedReader<Article> can be used where a FeedReader<FeedItem> is required.
  • in T is contravariant. FeedSink consumes T, so a FeedSink<FeedItem> can be used where a FeedSink<Article> is required.

The directions are worth checking at the call site rather than memorizing in isolation:

val articleReader: FeedReader<Article> = object : FeedReader<Article> {
    override fun snapshot(): List<Article> =
        listOf(Article("a1", "Kotlin state", null))
}

val itemReader: FeedReader<FeedItem> = articleReader // OK
val firstItem: FeedItem = itemReader.snapshot().first()

val itemSink: FeedSink<FeedItem> = object : FeedSink<FeedItem> {
    override fun accept(item: FeedItem) {
        println(item.id)
    }
}

val articleSink: FeedSink<Article> = itemSink // OK
articleSink.accept(articleReader.snapshot().first())

The reader substitution is safe because every article it returns is a feed item. The sink substitution is safe for the opposite reason: a sink capable of accepting any feed item can certainly accept an article.

Read “Declaration-site variance” in the Kotlin documentation. Notice that out and in appear on the interface declaration, so their promise applies to every use of that interface, not just to one variable.

Generics: in, out, where | Kotlin Documentation

Return to the Kotlin documentation for the rules behind the two feed interfaces.

In “Variance,” find the “Declaration-site variance” discussion. Start at the declaration rules, then continue through the Comparable example just below. Focus on where T appears in each interface's public members and how that determines the safe subtype assignment.

The following table is a quick visual summary, but read its “can read/write” columns as shorthand for whether an API can expose operations involving T in those positions. They do not mean an in T object can never return anything, or that every invariant class must offer both operations.

A simplified comparison of invariant `T`, covariant `out T`, and contravariant `in T`; its read/write marks summarize permitted uses of the declared type parameter in an API.

That distinction helps when reviewing proposed methods. FeedReader<out T> cannot add fun replace(item: T): accepting a T would break the producer-only promise. FeedSink<in T> cannot add fun latest(): T: callers of an article sink might actually be holding a sink for all feed items, so it could not promise to return an article. Either interface may still have methods unrelated to T, such as fun size(): Int.

If a boundary genuinely must both return and accept the same T, leave it invariant:

interface EditableStore<T : FeedItem> {
    fun get(): T
    fun replace(item: T)
}

Do not add out or in simply to make an assignment compile. First ask whether the API can honestly promise a producer-only or consumer-only role. If it can, separate interfaces can make those roles explicit; if it cannot, invariance is the safeguard.


Key takeaways

A generic bound says what each acceptable type must provide. Use where when the operation needs multiple capabilities, and retain T when preserving the caller’s specific type is useful. Declaration-site variance says how an API may use that type: out for a producer, in for a consumer, and invariance when it needs both directions.

In a code review or interview, justify the substitution with a concrete call: What could a caller read, or what could they pass in? That explanation is more reliable than reciting “producer out, consumer in” alone. Next, we’ll shift from API design to a hash-based array or string coding problem, where choosing the right lookup structure—and stating its costs—will matter.

Can't find a good explanation? Sign up and we'll make it for you

Sign up