skip to content

When designing a generic interface, how do you decide whether a type parameter should be `out`, `in`, or invariant? Walk through the trade-offs with concrete examples.

level: seniorimportance: should knowfreq 40%

answer

  1. produces → out, consumes → in, both → invariant
  2. split read/write interfaces to recover variance
  3. variance freezes T's direction (evolution cost)
  4. List<out E> vs MutableList<E>
  5. out/in widen caller substitutability

basics

~20 s

Look at how the type parameter is used. If the type only returns it, mark it out. If the type only accepts it, mark it in. If it does both, leave it invariant. The choice widens what callers can pass.

solid answer

~50 s

Decide by the **role** of the type parameter. Pure producers (only return T) → `out`, giving callers covariant flexibility (`Flow<out T>`-style, `List<out E>`, `Iterator<out T>`). Pure consumers (only accept T) → `in`, giving contravariant flexibility (`Comparable<in T>`, `Comparator<in T>`, callbacks). Types that both read and write T → invariant (`MutableList<E>`, `MutableMap<K, V>`, `Array<T>`). The design payoff is **substitutability at the boundary**: an `out` interface lets a `Repo<Cat>` flow into a `Repo<out Animal>` slot, and an `in` interface lets a single `Logger<Any>` serve every `Logger<T>` slot. The cost is that `out`/`in` constrains your own implementation — once you commit to `out T`, you can never add a method that takes T as a parameter without `@UnsafeVariance`. So variance is a forward-compatibility decision on a public surface; prefer it for stable read-only or write-only contracts and keep invariance where the type genuinely round-trips T.

code

kotlin · 6 lines
kotlin
interface ReadRepo<out T> { fun all(): List<T> }   // covariant producer
fun interface Logger<in T> { fun log(v: T) }       // contravariant consumer
interface Cache<T> {                                // invariant: both directions
    fun get(k: String): T?
    fun put(k: String, v: T)
}

go deeper

for a junior

Picks out for return-only and in for accept-only in simple cases.

for a middle

Correctly classifies producer/consumer/invariant and cites stdlib analogues.

for a senior

Weighs flexibility against API-evolution cost and proposes splitting read/write interfaces.

for a principal

Frames variance as a long-lived public-contract decision and reasons about source/binary compatibility implications across a library.

## The decision procedure Ask: **does T flow out, in, or both?** | Role of T | Modifier | Subtyping | stdlib example | |---|---|---|---| | Only returned (produced) | `out` | covariant (preserved) | `List<out E>`, `Iterable<out T>`, `Iterator<out T>` | | Only accepted (consumed) | `in` | contravariant (reversed) | `Comparable<in T>`, `Comparator<in T>` | | Both read and written | invariant (none) | unrelated | `MutableList<E>`, `Array<T>`, `MutableMap<K,V>` | ## Worked example — a read model ```kotlin interface ReadRepo<out T> { // produces only fun findById(id: String): T? fun all(): List<T> } fun renderAnimals(repo: ReadRepo<Animal>) { /* ... */ } val catRepo: ReadRepo<Cat> = ... renderAnimals(catRepo) // OK thanks to `out` ``` Marking it `out` means any `ReadRepo<Subtype>` satisfies a `ReadRepo<Supertype>` slot — maximal caller flexibility for a query-only contract. ## Worked example — a consumer ```kotlin fun interface Logger<in T> { // consumes only fun log(value: T) } val anyLogger: Logger<Any> = Logger { println(it) } val intLogger: Logger<Int> = anyLogger // OK: contravariance ``` One `Logger<Any>` can be reused wherever a `Logger<Int>`, `Logger<String>`, etc. is required. ## Worked example — must stay invariant ```kotlin interface Cache<T> { // both reads and writes T fun get(key: String): T? // out fun put(key: String, value: T) // in } ``` T appears in both positions, so neither `out` nor `in` is sound — leave it invariant. Trying to add either modifier triggers the position check. ## The trade-off: flexibility now vs. evolution later - **Pro of variance**: wider substitutability at the API boundary; fewer explicit casts or use-site projections by callers. - **Con**: it freezes the direction of T. Once public as `out T`, adding a consuming method is a breaking change (you'd need `@UnsafeVariance`, sacrificing soundness). Once `in T`, you can never return T. ## Practical guidance - Split read and write concerns into separate interfaces (`ReadRepo<out T>` + `WriteRepo<in T>`) so each can be variant; the combined `Repo<T>` stays invariant. This mirrors how Kotlin separates `List<out E>` from `MutableList<E>`. - Reserve invariance for types that genuinely round-trip T (mutable containers, arrays). - Treat the choice as a **public-API contract decision**, not an internal convenience.

  • How would you give a single combined `Repo<T>` variance without breaking soundness?
    You can't make the combined type variant if it both reads and writes T. Split it into `ReadRepo<out T>` and `WriteRepo<in T>` and have `Repo<T>` extend both; the variant flexibility lives in the split interfaces.
  • Is marking a type parameter `out` ever a breaking change for existing callers?
    It only relaxes assignability, so existing well-typed call sites keep compiling. The risk is to *implementers*: it forbids any future method that consumes T, so it constrains how the interface can evolve.

Choosing variance is like labeling a door one-way: an exit-only (out) or entry-only (in) door is more flexible for traffic flow, but you lose the ability to use it both ways later.

saying these in an interview costs you the question

  • Defaulting everything to `out` without checking the consume side
  • Marking a mutable container `out` or `in`
  • Not recognizing variance as a public-API/evolution commitment
  • Suggesting `@UnsafeVariance` as the normal way to mix directions
  • Failing to mention the read/write interface-split pattern

context