cswork / libraryOpen practice workspace

Articles

Kotlin Generics Interview Questions: Variance, Star Projections, and Reified Types

Practice Kotlin generics interview questions with variance, star projections, reified checks, and producer-consumer APIs that avoid unsafe type casts.

A function only reads animals, but it rejects a list of dogs. Another function accepts an unknown generic container, yet cannot safely insert even an ordinary value. A reified check recognizes a list and still says nothing reliable about every element. Kotlin generics becomes easier when you start with the operations a caller is allowed to perform.

For Kotlin generics interview questions, the central answer is operational: out supports safe production, in supports safe consumption, and an invariant type must support both directions without an unsafe widening. A star projection describes a safe view of an unknown argument. Reified parameters allow certain runtime checks, but do not recover all nested generic arguments.

Official language behavior: the variance and erasure rules below come from Kotlin’s generics reference. The API repairs and exercises are original preparation recommendations and inference; no candidate report or employer question-frequency claim is used.

Why is MutableList invariant?

Variance describes how a generic type's subtype relationship changes when its type argument changes. Covariance preserves the direction, contravariance reverses it, and invariance permits neither general substitution. The practical test is whether operations allowed through the substituted view could break the underlying value's type contract.

Kotlin's generics documentation explains declaration-site variance and use-site projections. Consider an original animal model with Dog : Animal. If a MutableList<Dog> were usable as a MutableList<Animal>, a caller could insert a Cat through the animal view. The original dog-list owner would then retrieve something that is not a dog.

The rejection protects both owners; it is not a compiler inconvenience to bypass with a cast. Before modifying a signature, ask whether the function needs to add, replace, or only inspect elements. Often the signature advertises more power than the function actually uses.

A read-only List<Dog> can be viewed as a List<Animal> because Kotlin's read-only list interface is covariant. However, “read-only view” is not a promise that the underlying collection cannot change through another alias. The collections overview distinguishes collection interfaces; immutability and ownership remain separate design concerns.

How does out express a producer contract?

Use a small original interface:

interface Source<out T> {
    fun next(): T
}

fun inspect(source: Source<Animal>) {
    val animal: Animal = source.next()
}

If a dog source supplies only dogs, the reader can safely treat each supplied item as an animal. The widening does not create a path for inserting a different animal. That is the reason out is safe here, not merely a mnemonic about arrows.

Try adding fun accept(value: T) to the same covariant interface. That input would undermine the widened view's guarantee. A useful interview response explains the unsafe call sequence rather than reporting only that the compiler marks a variance error.

The producer label is a guide to type positions, not a claim that an object must be stateless or have no methods taking arguments. A source may accept a timeout, cursor, or unrelated configuration type. What matters is how the declared type parameter appears in the public contract.

For more complicated function-type positions, calculate the direction rather than guessing from where the type is printed. A callback can reverse a position. Start with a simple method, establish the allowed assignment, then add the higher-order API and verify the compiler's constraints.

Why does in reverse the direction?

Suppose a sink can accept every animal:

interface Sink<in T> {
    fun accept(value: T)
}

fun trainDogs(sink: Sink<Dog>, dogs: List<Dog>) {
    dogs.forEach(sink::accept)
}

A Sink<Animal> can satisfy the dog-consuming requirement because every submitted dog is an animal. The sink that accepts more kinds of input is usable where only dog input is needed. This reverses the subtype relationship between the generic containers.

Do not reverse the explanation and claim that a dog-only sink can accept arbitrary animals. Test the specific assignment with a Cat value. If the substituted view authorizes that call but the implementation cannot handle it, the substitution is unsafe.

For a repository API, separating an input command consumer from an output result producer can make the contract clearer than one broad invariant interface. This is a design recommendation, not a requirement to split every repository. If an operation genuinely reads and writes the same type, invariance may be exactly correct.

What do use-site projections remove?

Sometimes the generic class cannot be covariant because it also supports writes. A particular function can still request a narrower view. In the original copying exercise below, the source supplies values and the destination accepts them:

fun <T> copyAll(source: Array<out T>, destination: Array<in T>) {
    require(destination.size >= source.size)
    for (i in source.indices) destination[i] = source[i]
}

The source projection prevents writing arbitrary T values through this view. The destination projection permits the writes, while reads from that view no longer promise the same precise element type. A projection changes what the function can safely do; it does not transform the actual array or copy anything by itself.

Explain the size precondition separately. Type safety does not prove that the destination has enough slots. A perfectly typed copying function can still fail on indexing or overwrite data the application wanted to preserve.

When the compiler rejects a source write, do not immediately use @UnsafeVariance or an unchecked cast. Those techniques move responsibility back to the author. First see whether the operation was unnecessary or belonged on a different interface with a stronger ownership contract.

What can you do with a star projection?

MutableList<*> means the element argument is unknown to this code, not that the list stores values of a new universal wildcard type. You can inspect its size and read elements as Any?. You cannot insert a chosen non-null value while preserving every possible actual element type.

The same reasoning rejects inserting null into an arbitrary MutableList<*>: the actual list may contain a non-null element type. The star projection has not proven that null is accepted. Operations that do not manufacture an unknown element, such as clearing a mutable list, can remain available.

For a producer whose parameter has a meaningful upper bound, reads use that bound rather than blindly becoming Any?. For a contravariant parameter, the safe input boundary is different. Kotlin's documentation describes these projection rules; the common phrase “star is Any” is too imprecise for an interview answer.

An original debugging task is to inspect heterogeneous repository handles. If the code needs only a display count, use the star-projected interface. If it needs to connect each handle's input to its output, erasing the relationship is the wrong operation. Preserve a generic parameter inside a function that owns both values.

What survives type erasure?

On the JVM, generic type arguments are generally erased from runtime instance checks. A runtime object can reveal that it is a List without proving that it is a List<String>. Kotlin's type-checks documentation distinguishes supported checks from unchecked generic casts.

The following original helper deliberately answers only an outer-type question:

inline fun <reified T> matches(value: Any?): Boolean = value is T

val mixed: Any = listOf(1, "two")
val outerMatches = matches<List<String>>(mixed)

On a JVM target, this kind of reified List check cannot certify all element values as strings. Treating the result as a complete parser or schema validator creates a false sense of safety. The caller's visible type expression and the instance's recoverable runtime evidence are not identical.

If you need a safe string list, first check the outer collection and then check each element. An empty list satisfies an all-elements predicate vacuously; that is an algorithmic policy, not proof of the original declared generic argument. Null elements also require an explicit nullable versus non-null contract.

A useful validation witness keeps the observed values separate from the erased type label. These are documentation-derived expectations for an element-by-element non-null string check, not a local compiler transcript:

Observed inputValidation decision
Empty listAccept if the contract permits empty input; no elements violate it.
Strings followed by an integerReject; checking the first element would miss the integer.
Strings plus nullReject for a non-null string contract.
Mutable string list later changed through an aliasThe old check cannot authorize future contents; copy validated values into owned storage.

This boundary matters when a JSON decoder or Java API returns loosely typed data. Returning the original object after a cast leaves both the unchecked element relationship and mutation ownership unresolved. State the required validation and copying policy independently.

Do not inspect only the first element. Later elements can differ, and a mutable collection can change after validation through another alias. Copying validated elements into owned storage gives a stronger boundary than returning an unchecked cast of the original mutable object.

What does reified require, and what does it not promise?

Kotlin's inline-functions documentation explains reified type parameters on inline functions. The compiler can specialize relevant type-dependent operations at call sites. This is not a general declaration that every generic object now stores a complete runtime type tree.

A useful reified helper is a bounded filterIsInstance-style operation over heterogeneous values. Its contract says it returns values passing the specified runtime check. If that requested type has nested generic arguments, state the erasure limitation instead of implying deep validation.

Consider a transport parser returning Result<T>. A reified parameter may help select a serializer registered for a known type, but serializer selection and payload validation are different steps. Missing fields, invalid numeric ranges, and unexpected nested values still need a decoding policy.

Inlining also does not eliminate every allocation or guarantee better performance. The purpose of this article's helper is type-aware checking, not a benchmark claim. If performance becomes part of the interview, measure the actual target and workload rather than extrapolating from the keyword.

Repair an API without weakening its safety

Take an original function that accepts MutableList<Animal> but only displays names. Replace its parameter with the least powerful readable interface it needs. Then test a dog list, an empty list, and a list changed by another owner during the application's lifecycle.

Next repair a function that consumes dog-training commands. Specify an input-only sink if that matches the implementation, and confirm that a broad animal sink can be substituted safely. Write one rejected assignment too: a cat-only sink must not suddenly become a dog sink because both are animals.

Finally, compare two operations on unknown containers: logging an element and appending a chosen element. The first can tolerate erased information; the second may require a type relationship you no longer possess. This forces the candidate to justify the abstraction rather than memorize syntax.

The examples here are documentation-reviewed exercises; no local Kotlin compiler result is claimed. Compile positive and negative cases in the intended Kotlin/JVM configuration, and keep diagnostics with the exact compiler version. Platform types and Java interop can add boundaries not exercised by these small pure-Kotlin signatures.

Sources and Further Reading

Research note: Official documentation checked October 1, 2026. Examples target ordinary Kotlin/JVM reasoning; compiler versions, platform types, interop, and application validation policies should be stated separately.