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.
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.
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("|"))
}
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.
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())
}
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.