Course outline

Kotlin / Reusable behavior and typed boundaries / Lesson 8

Use generics without unsafe type promises

Understand type parameters, invariant mutable containers, producer out, consumer in, and erased type checks.

About 4 min readContent reviewed
On this page

Lesson goals

  • Explain why a mutable list of strings is not a mutable list of arbitrary objects.
  • Read producer and consumer variance from allowed operations.

A type parameter preserves a relationship

A generic declaration expresses that some input and output types relate without fixing one concrete type. A Box<T> stores a T, and a function returning the first element of List<T> can preserve the caller’s element type. Generics are not a way to avoid thinking about the operations a value supports. Add a bound when the algorithm requires a capability, such as comparison. An unconstrained type parameter can include nullable types, while a bound such as T : Any excludes null. Choose restrictions that match the actual algorithm rather than casting inside it.

Mutable containers must protect both reads and writes

If MutableList<String> could be treated as MutableList<Any>, a caller could insert an integer and break the original string list’s promise. This is why mutability and subtyping require care. A read-only List is covariant in its element type because its interface does not accept arbitrary new elements through that type parameter. Read-only still does not mean deeply immutable. The example copies strings into a wider list explicitly, preserving the original container’s element contract rather than forcing an unsafe alias through a cast.

kotlin Widen through a read view or a separate container
fun main() {
    val names = mutableListOf("Mira", "Salim")
    val readView: List<Any> = names
    val wider = mutableListOf<Any>()
    wider.addAll(names)
    wider.add(7)
    println(readView.size)
    println(wider.joinToString("|"))
}
Expected output
2
Mira|Salim|7

Expected output is 2 and Mira|Salim|7. The integer is added only to the separate wider list. No unchecked cast changes the original list’s promised element type. This is uncompiled source.

Out produces values and in consumes them

Declaration-site out allows a type parameter only in permitted output positions, making a producer safely covariant. Declaration-site in allows permitted input positions and supports contravariance for a consumer. Think in operations: a source of strings can serve a reader expecting any object, while a sink accepting any object can serve code that only sends strings. Do not apply the mnemonic mechanically to properties that both read and write. A mutable property uses its type in both directions and therefore cannot satisfy an output-only promise without restrictions.

Express a narrow producer contract

The Source interface returns a value but accepts none of type T. A Source<String> can therefore be assigned to Source<Any> without allowing the caller to write an incompatible value into it. The implementation is local and has no resource lifetime. In real APIs, separate the type relationship from operational behavior such as blocking, failure, or ownership transfer; variance says nothing about those properties. Use-site projections can restrict operations on an existing generic type when changing its declaration is not appropriate, but readable API design should come before elaborate type gymnastics.

kotlin A covariant producer
interface Source<out T> { fun next(): T }
class NameSource : Source<String> {
    override fun next(): String = "Mira"
}
fun main() {
    val names: Source<String> = NameSource()
    val values: Source<Any> = names
    println(values.next())
}
Expected output
Mira

The expected line is Mira. Source<Any> lets the caller read an Any but provides no operation to insert an arbitrary object into NameSource. No compiler execution is claimed.

Runtime checks do not recover every generic argument

On the JVM, type erasure limits many runtime checks of generic arguments. Checking that a value is a List does not prove every element is a String. Star projection allows inspection without pretending to know the hidden type argument; validate elements when crossing an untyped boundary. Inline reified parameters support some runtime type operations at a call site, but they do not make nested generic arguments universally inspectable. Avoid unchecked casts that merely postpone a failure to a distant reader. Prefer a validated conversion that builds a correctly typed result and reports invalid elements explicitly.

Common errors and fixes

An unchecked list cast fails much later during reading.

Why it happens
Container shape was mistaken for element-type validation.
How to fix it
Inspect and convert elements at the untyped boundary.
Prevent it next time
Avoid unchecked casts in public conversion APIs.

An out parameter is used in a writable property.

Why it happens
The declaration both consumes and produces the parameter.
How to fix it
Split the interface or remove unsupported variance.
Prevent it next time
Review read and write positions before adding variance.

A generic function invokes methods unavailable on T.

Why it happens
The required capability was not expressed as a bound.
How to fix it
Add the narrow required bound or accept a behavior parameter.
Prevent it next time
Design around supported operations rather than concrete casts.

Check your understanding

1. Why is copying names into MutableList<Any> safer than casting names to that type?

Show worked solution

The copy has its own container and explicitly accepts wider values. Adding an integer cannot corrupt the original MutableList<String> element contract. A cast would make two references disagree about what the same mutable object may contain and can cause a later type failure. Element objects may still be shared, so copying the container is not deep cloning.

2. Can Source<out T> promise that next is nonblocking?

Show worked solution

No. Variance constrains type positions and safe substitution, not scheduling or resource behavior. A blocking producer and a pure constant producer can share that type shape. Document execution, error, and lifecycle semantics separately, or choose an asynchronous interface when the application needs one.

Key takeaways

  • Generics preserve relationships; variance restricts operations to keep substitution safe.
  • Validate erased boundaries instead of asserting hidden element types.

Official references