"Generics Without Wildcards" — Variance, Reified and Constraints
A Java repository signature full of ? extends and ? super meets Kotlin, and most of the wildcards disappear. We cover declaration-site variance with out and in, use-site and star projections, bounds and where clauses, nullable type parameters and T & Any, erasure and @JvmName, reified type parameters, and build a typed event envelope, router and repository.
Story Opening
Lena’s out and in stayed on the whiteboard all afternoon. Kabir’s first test was to implement the shared library’s LegacyRepository in Kotlin and let the IDE generate the method:
// Fragment of story/WildcardSoup.ktclass StoreRepository(private val stores: List<Store>) : LegacyRepository<Store, String> { // Kotlin shows the Java wildcards as projections: List<out Store>, Predicate<in Store>, Comparator<in Store>. override fun findAll(filter: Predicate<in Store>, order: Comparator<in Store>): List<out Store> = stores.filter { filter.test(it) }.sortedWith(order)}Every Java wildcard had come through, renamed: ? extends Store was now out Store, ? super Store was in Store. It was the same soup with different spelling.
“That’s the Java interface talking,” Lena said. “Java makes every use of a type say which way the data flows. Write the interface in Kotlin and you say it once, where the type is declared. Then most of those disappear.”
He rewrote the interface in Kotlin before lunch. It had one in left, on a Java type.
Java → Kotlin: The Quick Map
| Java | Kotlin | Note |
|---|---|---|
List<? extends Product> at every use | interface Feed<out T> once | Declaration-site variance: producer |
Comparator<? super Product> at every use | interface Sink<in T> once | Declaration-site variance: consumer |
List<? extends T> on an invariant type | MutableList<out T> | Use-site projection, still available |
List<?> | List<*> | Star projection |
<T extends Comparable<T>> | <T : Comparable<T>> | Upper bound |
<T extends A & B> | <T> … where T : A, T : B | Several bounds |
<T> (bound Object, nulls allowed anyway) | <T> (bound Any?) / <T : Any> | Nullability is part of the bound |
| (none) | T & Any | Definitely non-null version of a nullable T |
Class<T> type parameter | inline fun <reified T> | The class is available at runtime |
| Two overloads that erase alike: compile error | Same, fixed with @JvmName | JVM names differ; Kotlin callers don’t notice |
Raw type List | (none) | Every use needs a type argument; a Java raw List arrives as List<*>! |
String[] is an Object[] | Array<String> is not an Array<Any> | Arrays are invariant; use Array<out Any> |
Conceptual Deep-Dive
Variance belongs to the type, not to each use
A List<PerishableProduct> is not a List<Product> in Java, because a List can also accept elements: if it were, you could add a non-perishable Product to a list of perishables. Java’s answer is to make the type invariant and let each use opt into flexibility. List<? extends Product> reads safely and forbids add; Comparator<? super Product> accepts any comparator that can handle products. “Producer extends, consumer super” is the rule, and you apply it at every method signature, forever.
Kotlin asks the question once, when the type is declared. If a type parameter only ever comes out of an interface (return types), the interface is a producer and can be marked out T. If it only ever goes in (parameter types), it is a consumer, in T. The compiler checks the declaration, and every user gets the right subtyping for free:
This is why Kotlin’s read-only List<out E> is covariant (Part 7): a list you can only read can’t be used to insert the wrong thing. MutableList<E> can do both, so it stays invariant, and you fall back to Java-style use-site projections (MutableList<out Product>) when a function only reads from one. A type that both produces and consumes T, such as a repository with findAll and save, is invariant in T. The idiomatic fix is the one List and MutableList use: split it into a covariant read interface and an invariant read-write one that extends it (the hands-on does this).
Erasure is the same; reified is the exception
Kotlin generics compile to the same erased JVM generics as Java’s. At runtime a List<String> is a List, and checking value is List<String> on a value of type Any is a compile error, as is the equivalent instanceof in Java. The exception is an inline function (Part 5), whose body is copied into each caller: there, the caller’s actual type argument is known, so the compiler can mark a type parameter reified and use it as a real class (T::class, is T). That replaces the Java idiom of passing a Class<T> alongside every generic call.
Technical Explanation
Declaration-site variance: out and in
open class Product(val sku: String)class PerishableProduct(sku: String, val shelfLifeDays: Int) : Product(sku)
// out: T only comes out (return types). A Feed<PerishableProduct> is a Feed<Product>.interface Feed<out T> { fun next(): T // fun push(item: T) // error: type parameter 'T' is declared as 'out' but occurs in 'in' position in type 'T (of interface Feed<out T>)'.}
// in: T only goes in (parameters). A Sink<Product> is a Sink<PerishableProduct>.interface Sink<in T> { fun accept(item: T)}
class DairyFeed : Feed<PerishableProduct> { override fun next() = PerishableProduct("SHW-3002", 5)}
class AuditSink : Sink<Product> { override fun accept(item: Product) = println("audited ${item.sku}")}
fun firstOf(feed: Feed<Product>): Product = feed.next()
fun deliver(item: PerishableProduct, sink: Sink<PerishableProduct>) = sink.accept(item)
fun main() { println(firstOf(DairyFeed()).sku) // -> SHW-3002 deliver(PerishableProduct("SHW-3001", 30), AuditSink()) // -> audited SHW-3001
// The standard library does the same: List<out E> is covariant, MutableList<E> is not. val perishables: List<PerishableProduct> = listOf(PerishableProduct("SHW-3002", 5)) val products: List<Product> = perishables // fine: a read-only List can only hand Products out println(products.size) // -> 1
val shelf: MutableList<PerishableProduct> = mutableListOf() // val anyShelf: MutableList<Product> = shelf // error: initializer type mismatch: expected 'MutableList<Product>', actual 'MutableList<PerishableProduct>'. println(shelf.isEmpty()) // -> true}The compiler enforces the positions. An out T may appear in return types, in val property types and in the type arguments of other out positions. It may not appear as a parameter type, which is the commented-out push. An in T is the mirror image. Positions compose: in fun handle(envelope: Envelope<E>), E sits inside a covariant type in a parameter, which counts as an in position, so a contravariant Handler<in E> may declare it (the hands-on does exactly that).
When you know a use is safe but the compiler can’t see it, @UnsafeVariance overrides the check. The standard library’s List<out E>.contains(element: @UnsafeVariance E) is the canonical example.
What Java sees: wildcards come back
Declaration-site variance is a Kotlin concept, so for Java callers the compiler translates it into use-site wildcards in the signatures it emits:
// Parameters of covariant types get Java wildcards when the type argument is open or an interface.fun countProducts(products: List<Product>): Int = products.size
fun countSkus(skus: List<String>): Int = skus.size // String is final: no wildcard
// Contravariant parameters get '? super', except for Any (every type is a subtype of Object anyway).fun auditWith(sink: Sink<Product>) = sink.accept(Product("SHW-1001"))
fun auditAnything(sink: Sink<Any>) = sink.accept("anything")
// Opting out per type: Java callers and reflection see List<Product>.fun countExact(products: @JvmSuppressWildcards List<Product>): Int = products.size
fun main() { println(countProducts(listOf(Product("SHW-1001"))) + countSkus(listOf("SHW-2040"))) // -> 2 auditWith(AuditSink()) // -> audited SHW-1001 auditAnything(object : Sink<Any> { override fun accept(item: Any) = println("saw $item") }) // -> saw anything println(countExact(emptyList())) // -> 0}// javap -p WhatJavaSeesKt, EventRouter, ReadRepository (simplified)public static final int countProducts(java.util.List<? extends Product>); // Product is openpublic static final int countSkus(java.util.List<java.lang.String>); // String is finalpublic static final void auditWith(Sink<? super Product>);public static final void auditAnything(Sink<java.lang.Object>); // nothing is above Objectpublic static final int countExact(java.util.List<Product>); // @JvmSuppressWildcardspublic final <E extends CatalogEvent> void register(KClass<E>, Handler<? super E>);public interface ReadRepository<T, ID> { // no variance on the declaration public abstract java.util.List<T> findAll(Function1<? super T, Boolean>, java.util.Comparator<? super T>);}The rule: a parameter whose type has a covariant (out) type parameter gets ? extends, unless the type argument is final, where a wildcard would add nothing (List<String> stays as is). A contravariant parameter gets ? super, unless the argument is Any. Return types never get wildcards, because Java callers would then have to deal with them. So Java callers of Kotlin code get the flexibility of PECS without anyone writing it. @JvmSuppressWildcards on a type, a function or a class removes the wildcards where a Java tool expects exact types, and @JvmWildcard adds one where Kotlin wouldn’t.
Generic extension functions
data class Product(val sku: String, val pricePaise: Long)
// An extension on one parameterisation: Java can't add a method to List<Product> only.fun List<Product>.totalPaise(): Long = sumOf { it.pricePaise }
// A bounded generic receiver: available on any List<T> whose T is Comparable.fun <T : Comparable<T>> List<T>.isAscending(): Boolean = zipWithNext().all { (a, b) -> a <= b }
fun main() { val basket = listOf(Product("SHW-1001", 16_500), Product("SHW-2040", 72_000)) println(basket.totalPaise()) // -> 88500 println(listOf("SHW-1001", "SHW-2040").isAscending()) // -> true // listOf(Product("SHW-1001", 1)).isAscending() // error: candidate 'fun <T : Comparable<T>> List<T>.isAscending(): Boolean' is inapplicable because of a receiver type mismatch.
// Nothing + out: a List<Nothing> is a List<Product>, because Nothing is a subtype of every type. val nothing: List<Nothing> = listOf() val none: List<Product> = nothing println(none.totalPaise()) // -> 0}Java’s generic helpers are static methods in utility classes, and they can’t target one parameterisation: there is no way to add a method to List<Product> and not to List<String>. A Kotlin extension can (totalPaise), and with a bound on its type parameter, it shows up only on receivers that satisfy it (isAscending on List<String>, not on List<Product>). Erasure still applies to extensions: two that differ only in the receiver’s type argument, such as List<Int>.sumAll(): Long and List<Long>.sumAll(): Long, clash like the functions in the erasure section below. The last lines show covariance and Nothing working together: because Nothing is a subtype of every type (Part 2) and List is covariant, a List<Nothing> is a List<Product>. The standard library relies on this: emptyList() returns one shared EmptyList object, typed List<Nothing>, for every element type.
Use-site projections and star projection
// Use-site projections: Java's ? extends / ? super, for types that are invariant by declaration.fun copyAll(from: MutableList<out Product>, to: MutableList<in Product>) { for (item in from) to.add(item) // read from 'out', write to 'in' // from.add(Product("SHW-0000")) // error: receiver type 'MutableList<out Product>' contains out projection which prohibits the use of 'fun add(element: E): Boolean'.}
// Star projection: "some type I don't know". Safe to read (as Any?), not to write.fun describe(items: MutableList<*>): String = "${items.size} items, first is ${items.firstOrNull()}"// fun addTo(items: MutableList<*>) = items.add("x") // error: receiver type 'MutableList<*>' contains star projection which prohibits the use of 'fun add(element: E): Boolean'.
fun main() { val dairy = mutableListOf(PerishableProduct("SHW-3002", 5)) val everything = mutableListOf<Any>("header") copyAll(dairy, everything) println(everything.size) // -> 2 println(describe(mutableListOf(1, 2, 3))) // -> 3 items, first is 1}For invariant types like MutableList, the Java toolkit is still there. MutableList<out Product> is List<? extends Product>: you can read Products, and the compiler removes add from the projected type. MutableList<in Product> is List<? super Product>: you can add products, and reading gives Any?. MutableList<*> is List<?>, readable as Any? and not writable at all. The compiler messages name the projection that prohibits the call, which is more helpful than Java’s capture-of-? errors.
Bounds, where, and nullable type parameters
// Upper bound, like <T extends Comparable<T>>.fun <T : Comparable<T>> maxOf3(a: T, b: T, c: T): T = maxOf(a, maxOf(b, c))
// Several bounds need 'where', like <T extends CharSequence & Comparable<T>>.fun <T> longestSorted(items: List<T>): T? where T : CharSequence, T : Comparable<T> = items.sorted().maxByOrNull { it.length }
// The default bound is Any?, not Object: an unbounded T accepts null.fun <T> describe(value: T): String = "value=$value"
// T : Any rules nulls out.fun <T : Any> describeNonNull(value: T): String = "value=$value"
// Definitely non-null type T & Any: "T, but never null", for a T that may itself be nullable.fun <T> orDefault(value: T, default: T & Any): T & Any = value ?: default
fun main() { println(maxOf3(16_500, 72_000, 34_000)) // -> 72000 println(longestSorted(listOf("dal", "basmati", "ghee"))) // -> basmati println(describe(null)) // -> value=null // describeNonNull(null) // error: null cannot be a value of a non-null type 'uninferred T (of fun <T : Any> describeNonNull)'. val maybeName: String? = null println(orDefault(maybeName, "unknown").length) // -> 7}Bounds work like Java’s, with : instead of extends, and where for more than one. The difference that matters is nullability. An unbounded <T> has the upper bound Any?, so describe(null) compiles and T may be a nullable type. In Java the bound is Object, and nobody thinks about null because the type system doesn’t track it. In Kotlin it does, so decide per type parameter: <T : Any> if null makes no sense.
T & Any (Stable since Kotlin 1.7) is for the other case: T itself may be nullable, but this particular value is known not to be. orDefault returns T & Any, so the caller gets a non-null String even though T was inferred as String?. You’ll mostly meet it when overriding Java generic methods annotated @NotNull.
Erasure and @JvmName
// Both erase to total(java.util.List): @JvmName gives them different JVM names.@JvmName("totalPaise")fun total(prices: List<Long>): Long = prices.sum()
@JvmName("totalUnits")fun total(units: List<Int>): Long = units.sum().toLong()
fun main() { val prices: List<Long> = listOf(16_500, 3_050) val units: List<Int> = listOf(120, 35) println(total(prices)) // -> 19550 println(total(units)) // -> 155 // Integer literals fit both List<Int> and List<Long>, so an untyped listOf is ambiguous: // total(listOf(120, 35)) // error: overload resolution ambiguity between candidates:}Both total functions erase to total(java.util.List) and return long, so their JVM signatures are identical (with different return types they would not clash). Kotlin’s overload resolution can tell them apart, but the JVM can’t hold both, and without the annotations the compiler reports platform declaration clash: The following declarations have the same JVM signature (total(Ljava/util/List;)J). @JvmName gives each a distinct JVM name, totalPaise and totalUnits, while Kotlin callers keep writing total(…). Java callers use the JVM names.
Reified type parameters
sealed interface CatalogEvent { val sku: String}
data class PriceChanged(override val sku: String, val toPaise: Long) : CatalogEventdata class StockChanged(override val sku: String, val units: Int) : CatalogEvent
// Java needs a Class<T> parameter for this. A reified type parameter is available at runtime,// because the inline function is copied into each call site with the real class.inline fun <reified T : CatalogEvent> List<CatalogEvent>.only(): List<T> = filter { it is T }.map { it as T }
inline fun <reified T> typeName(): String = T::class.simpleName ?: "?"
// fun <T> typeNameOf(): String = typeName<T>() // error: cannot use 'T' as reified type parameter. Use a class instead.
fun main() { val events = listOf(PriceChanged("SHW-1001", 15_900), StockChanged("SHW-1001", 6), PriceChanged("SHW-2040", 69_000)) println(events.only<PriceChanged>().map { it.toPaise }) // -> [15900, 69000] println(typeName<StockChanged>()) // -> StockChanged
// The standard library's version of the same idea: println(events.filterIsInstance<StockChanged>()) // -> [StockChanged(sku=SHW-1001, units=6)]
// Erasure still applies to ordinary generic types at runtime: from Any, a List<String> is just a List. val anything: Any = listOf("SHW-1001") // println(anything is List<String>) // error: cannot check for instance of erased type 'List<String>'. println(anything is List<*>) // -> true}only<PriceChanged>() works because the compiler copies only’s body into main with PriceChanged substituted. The call site in bytecode contains the class literally:
// javap -c ReifiedKt.main (simplified)114: checkcast CatalogEvent124: instanceof com/shelfwise/part08/reified/PriceChanged // 'is T', with T = PriceChangedThe limits follow from the mechanism:
- Only on
inlinefunctions, and only where the caller knows the type. A generic function that passes its own non-reifiedTalong gets the error in the commented-out line. The fix is to make that functioninlineandreifiedtoo, or to pass aKClass<T>. - Not from Java. A function with reified type parameters is compiled as a synthetic method (
ACC_SYNTHETIC), invisible tojavac. Offer aKClass<T>orClass<T>overload next to the reified one, as the hands-on router does withregister. - Only the class, not the full type.
T::classforT = List<String>isList::class. Nested type arguments are still erased. (typeOf<T>()fromkotlin.reflectcaptures the full type, at some runtime cost.) - Only function type parameters. A class’s own type parameter can’t be reified, and even a reified
Tcan’t be constructed withT(). Pass a factory (() -> T) for that.
Step-by-Step Hands-On: Typed Events and a Variance-Aware Repository
Code: kotlin-for-java-survivors/language/part08-generics-without-wildcards (file events/TypedEvents.kt).
The catalog service will publish product events (Part 14) and the AI service will consume them (Part 15). Kabir works out the generic plumbing such a pair of services needs: an envelope, a handler type, a router, and the repository from the whiteboard.
Step 1 — The events and a covariant envelope. An envelope only hands its event out, so E is out:
// Fragment of events/TypedEvents.ktsealed interface CatalogEvent { val sku: String}
data class ProductAdded(override val sku: String, val name: String) : CatalogEventdata class PriceChanged(override val sku: String, val toPaise: Long) : CatalogEventdata class StockChanged(override val sku: String, val units: Int) : CatalogEvent
// Step 1: an envelope only hands its event out, so it is covariant.// An Envelope<PriceChanged> is an Envelope<CatalogEvent>.data class Envelope<out E : CatalogEvent>(val key: String, val sequence: Long, val event: E)Step 2 — A contravariant handler. A handler only takes envelopes in, so E is in. A fun interface (Part 5), so handlers can be lambdas:
// Fragment of events/TypedEvents.kt// Step 2: a handler only takes envelopes in, so it is contravariant.// A Handler<CatalogEvent> can stand in for a Handler<PriceChanged>.fun interface Handler<in E : CatalogEvent> { fun handle(envelope: Envelope<E>)}Step 3 — The router. register takes an explicit KClass<E> token, which Java callers can use; on is the reified convenience for Kotlin. Inside, the handlers of different event types share one map, so one unchecked cast is unavoidable. It is safe because each handler is stored under the class it was registered for, and the annotation says so:
// Fragment of events/TypedEvents.kt// Step 3: a router keyed by event class. The class token is explicit in register(),// and comes from a reified type parameter in on().class EventRouter { private val handlers = mutableMapOf<KClass<*>, MutableList<Handler<*>>>()
fun <E : CatalogEvent> register(type: KClass<E>, handler: Handler<E>) { handlers.getOrPut(type) { mutableListOf() } += handler }
inline fun <reified E : CatalogEvent> on(handler: Handler<E>) = register(E::class, handler)
fun dispatch(envelope: Envelope<CatalogEvent>) { for (handler in handlers[envelope.event::class].orEmpty()) { @Suppress("UNCHECKED_CAST") // safe: handlers are registered under their event's own class (handler as Handler<CatalogEvent>).handle(envelope) } }}Step 4 — The repository, declared once. Compare it with the Java interface from the opening. In the read interface, T is only returned, so out T. ID is only passed in, so in ID. List<T> needs no wildcard because List is already covariant, and (T) -> Boolean needs none because function types declare their own variance (Function1<in P, out R>). Only Comparator, a Java interface without declaration-site variance, still needs a use-site in. Saving takes a T in, so the read-write Repository extends the read interface with an invariant T:
// Fragment of events/TypedEvents.kt// Step 4: the repository from the whiteboard, with variance declared once.interface ReadRepository<out T : Any, in ID> { fun findById(id: ID): T?
fun findAll(filter: (T) -> Boolean = { true }, order: Comparator<in T>? = null): List<T>}
// Writing takes T in, so T is invariant here: split the interface, as List and MutableList do.interface Repository<T : Any, in ID> : ReadRepository<T, ID> { fun save(entity: T)}
class InMemoryRepository<T : Any, ID>(items: List<T>, private val idOf: (T) -> ID) : Repository<T, ID> { private val byId = items.associateBy(idOf).toMutableMap()
override fun findById(id: ID): T? = byId[id]
override fun save(entity: T) { byId[idOf(entity)] = entity }
override fun findAll(filter: (T) -> Boolean, order: Comparator<in T>?): List<T> { val matching = byId.values.filter(filter) return if (order == null) matching else matching.sortedWith(order) }}
data class Product(val sku: String, val name: String, val pricePaise: Long)Step 5 — Use it.
// Fragment of events/TypedEvents.ktfun main() { val router = EventRouter() router.on<PriceChanged> { println("reprice ${it.event.sku} -> ${it.event.toPaise}") } router.on<StockChanged> { println("stock ${it.event.sku}: ${it.event.units}") }
// One audit handler for every event type, thanks to 'in E'. val audit = Handler<CatalogEvent> { println("audit #${it.sequence} ${it.event.sku}") } router.on<ProductAdded>(audit) router.on<PriceChanged>(audit)
// A handler registered for the supertype never fires: dispatch looks up the exact runtime class. router.on<CatalogEvent> { println("never printed") }
val added: Envelope<ProductAdded> = Envelope("SHW-1001", 1, ProductAdded("SHW-1001", "Toor Dal 1kg")) val priced: Envelope<PriceChanged> = Envelope("SHW-1001", 2, PriceChanged("SHW-1001", 15_900)) val stocked: Envelope<StockChanged> = Envelope("SHW-1001", 3, StockChanged("SHW-1001", 6)) // dispatch takes an Envelope<CatalogEvent>; an Envelope<PriceChanged> fits only because of 'out E'. listOf(added, priced, stocked).forEach(router::dispatch) // -> audit #1 SHW-1001 // -> reprice SHW-1001 -> 15900 // -> audit #2 SHW-1001 // -> stock SHW-1001: 6
val products: Repository<Product, String> = InMemoryRepository( listOf(Product("SHW-2040", "Basmati 5kg", 72_000), Product("SHW-1001", "Toor Dal 1kg", 16_500)), ) { it.sku } // A Comparator<Any> works where a Comparator<in Product> is expected. val byText: Comparator<Any> = compareBy { it.toString() } println(products.findAll(order = byText).map { it.sku }) // -> [SHW-1001, SHW-2040] products.save(Product("SHW-2040", "Basmati 5kg (new pack)", 69_000)) println(products.findById("SHW-2040")?.name) // -> Basmati 5kg (new pack)
// A read-only view of it is a ReadRepository<Any, String>, thanks to 'out T'. val anything: ReadRepository<Any, String> = products println(anything.findAll().size) // -> 2}Each variance annotation pays for itself in the output. audit, a Handler<CatalogEvent>, registers as a handler for ProductAdded and PriceChanged with no adapter (in E). An Envelope<PriceChanged> goes to dispatch(Envelope<CatalogEvent>) with no cast (out E). A Comparator<Any> sorts products. And the repository, seen through its read interface, is usable as a ReadRepository<Any, String> (out T). The never printed handler is the one trap, covered in the Gotchas.
Tips, Tricks & Gotchas
Gotcha — an unbounded
Tacceptsnull. Java developers read<T>as “some object”. In Kotlin its bound isAny?, sodescribe(null)compiles and a generic cache or repository will happily store nulls. Write<T : Any>unlessnullis a legitimate value.
Gotcha — integer literals make erased overloads ambiguous. In Java,
List.of(120, 35)is aList<Integer>. In Kotlin, an integer literal can becomeIntorLong, sototal(listOf(120, 35))matches bothtotal(List<Int>)andtotal(List<Long>)and fails with “overload resolution ambiguity”. Give the list an explicit type, or give the overloads different names.
Gotcha — arrays are invariant. In Java, a
String[]is anObject[], and storing anIntegerinto it compiles and throwsArrayStoreExceptionat runtime. Kotlin rejectsval anything: Array<Any> = skusat compile time. WriteArray<out Any>for a parameter that only reads:
// Java arrays are covariant: String[] is an Object[], and a wrong write fails at runtime.// Kotlin arrays are invariant; use-site 'out' gives the read-only flexibility.fun printAll(items: Array<out Any>) = println(items.joinToString())
fun main() { val skus: Array<String> = arrayOf("SHW-1001", "SHW-2040") // val anything: Array<Any> = skus // error: initializer type mismatch: expected 'Array<Any>', actual 'Array<String>'. printAll(skus) // -> SHW-1001, SHW-2040
// The Java behaviour, through a Java array type: compiles, then fails at runtime. val javaStyle: Array<Any> = java.lang.reflect.Array.newInstance(String::class.java, 1) as Array<Any> try { javaStyle[0] = 42 } catch (e: ArrayStoreException) { println("ArrayStoreException: ${e.message}") // -> ArrayStoreException: java.lang.Integer }}Gotcha — a supertype handler never fires. Contravariance makes a
Handler<CatalogEvent>assignable to aHandler<PriceChanged>; it does not make a router find it.router.on<CatalogEvent> { … }registers underCatalogEvent::class, anddispatchlooks up the event’s exact runtime class, so the handler stays silent. Register the general handler per concrete type (asauditis), or make dispatch walk the event’s supertypes.
Gotcha — your
List<Product>parameter isList<? extends Product>to Java. Most code never notices; Spring, for example, resolvesList<? extends Handler>constructor injection fine. Tools that match generic signatures exactly do notice. Dagger is the well-known case, where multibindings need@JvmSuppressWildcardson the type. Put it on the type, the function or the class.
Key Takeaways
| Concept | Remember |
|---|---|
out T | Producer: T only in return positions; Feed<Sub> is a Feed<Super> |
in T | Consumer: T only in parameter positions; Sink<Super> is a Sink<Sub> |
| Use-site projections | MutableList<out T> / <in T> / <*> for invariant types, like Java wildcards |
| Java interop | Kotlin emits ? extends/? super in parameter signatures (not for final type arguments or Any, not in return types); reified functions are invisible to Java |
| Generic extensions | Can target one parameterisation (List<Product>) or a bounded T |
| Arrays | Invariant, unlike Java’s; Array<out T> to read |
| Bounds | <T : Bound>, where for several; default bound is Any? |
T & Any | Definitely non-null form of a nullable type parameter |
| Erasure | Same as Java; erased overloads need @JvmName |
reified | Inline functions only; class available at runtime; not from Java; nested arguments still erased |
Story Closing
The event router and the Kotlin Repository went into the shared library alongside the Java interface, with no casts in the calling code and one in on a Java type.
The Java pricing team adopted the repository the same week. On Thursday, a message from their lead arrived in the platform channel, with a screenshot of a compile error:
error: exception IOException is never thrown in body of corresponding try statement
“Your FileBackedRepository.load() throws IOException when the export is missing,” she wrote. “We saw it in production logs. But javac won’t let us catch it. How do we catch an exception that Java says can’t happen?”
Kabir looked at his function. It did throw IOException, and nothing in its signature said so, because Kotlin doesn’t have checked exceptions.
In Part 9, Kabir learns how Kotlin models failure without checked exceptions, and what that means for the Java code calling it.
This is Part 8 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”