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.kt
class 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

JavaKotlinNote
List<? extends Product> at every useinterface Feed<out T> onceDeclaration-site variance: producer
Comparator<? super Product> at every useinterface Sink<in T> onceDeclaration-site variance: consumer
List<? extends T> on an invariant typeMutableList<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 : BSeveral bounds
<T> (bound Object, nulls allowed anyway)<T> (bound Any?) / <T : Any>Nullability is part of the bound
(none)T & AnyDefinitely non-null version of a nullable T
Class<T> type parameterinline fun <reified T>The class is available at runtime
Two overloads that erase alike: compile errorSame, fixed with @JvmNameJVM 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:

graph LR subgraph "out T (producer): same direction" FP["Feed#lt;PerishableProduct#gt;"] -->|is a| FPr["Feed#lt;Product#gt;"] end subgraph "in T (consumer): reversed" SPr["Sink#lt;Product#gt;"] -->|is a| SP["Sink#lt;PerishableProduct#gt;"] end

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 open
public static final int countSkus(java.util.List<java.lang.String>); // String is final
public static final void auditWith(Sink<? super Product>);
public static final void auditAnything(Sink<java.lang.Object>); // nothing is above Object
public static final int countExact(java.util.List<Product>); // @JvmSuppressWildcards
public 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) : CatalogEvent
data 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 CatalogEvent
124: instanceof com/shelfwise/part08/reified/PriceChanged // 'is T', with T = PriceChanged

The limits follow from the mechanism:

  • Only on inline functions, and only where the caller knows the type. A generic function that passes its own non-reified T along gets the error in the commented-out line. The fix is to make that function inline and reified too, or to pass a KClass<T>.
  • Not from Java. A function with reified type parameters is compiled as a synthetic method (ACC_SYNTHETIC), invisible to javac. Offer a KClass<T> or Class<T> overload next to the reified one, as the hands-on router does with register.
  • Only the class, not the full type. T::class for T = List<String> is List::class. Nested type arguments are still erased. (typeOf<T>() from kotlin.reflect captures 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 T can’t be constructed with T(). 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.kt
sealed interface CatalogEvent {
val sku: String
}
data class ProductAdded(override val sku: String, val name: String) : CatalogEvent
data class PriceChanged(override val sku: String, val toPaise: Long) : CatalogEvent
data 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.kt
fun 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 T accepts null. Java developers read <T> as “some object”. In Kotlin its bound is Any?, so describe(null) compiles and a generic cache or repository will happily store nulls. Write <T : Any> unless null is a legitimate value.

Gotcha — integer literals make erased overloads ambiguous. In Java, List.of(120, 35) is a List<Integer>. In Kotlin, an integer literal can become Int or Long, so total(listOf(120, 35)) matches both total(List<Int>) and total(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 an Object[], and storing an Integer into it compiles and throws ArrayStoreException at runtime. Kotlin rejects val anything: Array<Any> = skus at compile time. Write Array<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 a Handler<PriceChanged>; it does not make a router find it. router.on<CatalogEvent> { … } registers under CatalogEvent::class, and dispatch looks up the event’s exact runtime class, so the handler stays silent. Register the general handler per concrete type (as audit is), or make dispatch walk the event’s supertypes.

Gotcha — your List<Product> parameter is List<? extends Product> to Java. Most code never notices; Spring, for example, resolves List<? extends Handler> constructor injection fine. Tools that match generic signatures exactly do notice. Dagger is the well-known case, where multibindings need @JvmSuppressWildcards on the type. Put it on the type, the function or the class.


Key Takeaways

ConceptRemember
out TProducer: T only in return positions; Feed<Sub> is a Feed<Super>
in TConsumer: T only in parameter positions; Sink<Super> is a Sink<Sub>
Use-site projectionsMutableList<out T> / <in T> / <*> for invariant types, like Java wildcards
Java interopKotlin emits ? extends/? super in parameter signatures (not for final type arguments or Any, not in return types); reified functions are invisible to Java
Generic extensionsCan target one parameterisation (List<Product>) or a bounded T
ArraysInvariant, unlike Java’s; Array<out T> to read
Bounds<T : Bound>, where for several; default bound is Any?
T & AnyDefinitely non-null form of a nullable type parameter
ErasureSame as Java; erased overloads need @JvmName
reifiedInline 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.”