Kotlin 2.x, KSP & Collections Explained

KOTLIN › Modern Kotlin

Interviewers use this cluster, K2, KSP, and collections, to separate people who write Kotlin from people who understand how it compiles and behaves. All three come up in almost every mid-to-senior Android interview, so it pays to know them precisely rather than vaguely.

Start with the compiler. Kotlin has a **frontend** that reads your code and works out what it means: type checking, resolving which function a call refers to, inferring types. And a **backend** that turns that resolved code into runnable bytecode for the JVM, JS, or Native. K2 is a from-scratch rewrite of the frontend only, built around a new internal representation called FIR. The backends that emit bytecode are unchanged.

K2 became **Stable and the default compiler in Kotlin 2.0.0**. The concrete payoff is faster builds, especially incremental ones, and the IDE and the command-line compiler now behave consistently, because both run the same FIR-based analysis engine. Smarter smart casts, KSP replacing kapt, and the collection semantics interviewers love to probe all sit on top of that one fact.

One concrete win from K2 is **smarter smart casts**. Pre-K2, a lot of provably safe patterns still forced an explicit cast. K2 propagates the cast further: into a lambda that captures a local val after a type check, after an || where both sides check the same type, casting to their common supertype, inside a lambda passed to an inline function, on a nullable property whose type is itself a function type, and right after an increment or decrement.

fun greet(obj: Any) {
    if (obj is String) {
        val fn = { println(obj.length) }
        fn()
    }
}

The common thread across all of those cases: whenever the compiler can prove the value could not have changed since the check, K2 is willing to trust that proof. The old frontend often wasn't, even when the proof was just as valid, and made you write the cast by hand anyway.

**KSP**, Kotlin Symbol Processing, is the modern replacement for **kapt** when generating code from annotations, think Room, Moshi, Hilt. kapt works by generating Java stub classes from your Kotlin source, then running the Java annotation processor, javac or apt, over those stubs, an extra round trip through Java. KSP skips that entirely: it reads Kotlin symbols directly from the compiler, no stub generation needed.

// kapt: generates Java stubs, runs javac, slower
plugins { id("kotlin-kapt") }

// KSP: reads Kotlin symbols directly, roughly 2x faster
plugins { id("com.google.devtools.ksp") }

That stub-and-javac round trip is the bottleneck kapt can't avoid, which is why KSP is roughly twice as fast, and why kapt is now in **maintenance mode**, new libraries target KSP.

One more name worth knowing precisely: **KSP2**. It is not a kapt-style stub generator and it is not a Gradle caching layer. It's a redesigned KSP implementation with a cleaner internal API, built specifically to work correctly with the K2 compiler.

KSP's model is deliberately **read-only**. A SymbolProcessor can inspect declarations, classes, functions, properties, parameters, and their annotations, and it can generate brand-new files. What it can't do is rewrite your existing source, and it can't see inside function bodies, no statements, no expressions, just the shape of the declarations themselves.

override fun process(resolver: Resolver): List<KSAnnotated> {
    resolver.getSymbolsWithAnnotation("com.example.MyAnnotation")
        .filterIsInstance<KSClassDeclaration>()
        .forEach { klass ->
            // read the declaration, then generate a NEW file, never edit klass's source
            env.codeGenerator.createNewFile(/* ... */)
        }
    return emptyList()
}

That constraint isn't a limitation to work around, it's what keeps annotation processing safe to run incrementally and in parallel: nothing a processor does can corrupt or race with your actual source files.

Kotlin splits collections into read-only interfaces, List, Set, Map, and mutable interfaces, MutableList, MutableSet, MutableMap. This trips people up constantly: **read-only does not mean immutable**. A read-only List is just a restricted view, it exposes no add or remove, but the object underneath can still be a MutableList, and if some other reference to that same object mutates it, the change shows up through the read-only view too, because it was never a copy.

val backing = mutableListOf(1, 2, 3)
val readOnly: List<Int> = backing

backing.add(4)
println(readOnly.size)

The view and the backing list are the same object in memory. Restricting what a reference is allowed to do says nothing about whether the object it points at can change through some other reference.

Two different kinds of "can't change" get mixed up constantly. First, val: it fixes the reference, not the object. val numbers = mutableListOf("a") means you can never point numbers at a different list, but you can still add, remove, or update its contents, because those operations don't touch the reference at all.

val numbers = mutableListOf("a")
numbers.add("b")     // fine, mutates contents
numbers.remove("a")  // fine
// numbers = mutableListOf("c")  // compile error, val is fixed

Second, the type hierarchy: MutableList<T> isn't a separate thing from List<T>, it's a subinterface. MutableList extends List and adds the mutating methods, add, remove, set. That's exactly why you can hand a MutableList to code that only expects a List: the upcast costs nothing and copies nothing, the object underneath is identical either way, only what the reference is allowed to do to it changes.

So how do you get a collection that genuinely cannot change through any reference, not a val, not a read-only view? Kotlin's standard library doesn't have one built in. You reach for a **persistent collection** from the separate kotlinx.collections.immutable library. A persistentListOf has no backing mutable object at all, calling add on it doesn't mutate anything, it returns a brand new list and leaves the original untouched.

import kotlinx.collections.immutable.persistentListOf

val immutable = persistentListOf(1, 2, 3)
val grown = immutable.add(4)

println(immutable.size)  // 3, unchanged
println(grown.size)      // 4, a different list

Compare that with the val-and-read-only-view tricks from the last two chunks. Both of those still have exactly one mutable object living somewhere, with multiple references pointing at it. A persistent collection has no such object to mutate in the first place, so there's nothing for any reference to corrupt.

**Array and List are not interchangeable.** Array has a fixed size set at creation, it is not part of the Collection hierarchy at all, and it compiles to a real JVM array, primitive or object. List is a Collection interface; its mutable form, MutableList, typically backed by ArrayList, can grow and shrink dynamically.

val arr = arrayOf(1, 2, 3)
// val col: Collection<Int> = arr   // ERROR: Array isn't a Collection

val list: List<Int> = listOf(1, 2, 3)
val col: Collection<Int> = list   // fine

val mutable = mutableListOf(1, 2, 3)
mutable.add(4)                    // grows; arr.size can never change

Reach for Array mainly for fixed-size, performance-sensitive data, especially primitive arrays, which avoid boxing. Reach for List or MutableList for everything else, including any time you need to hand the data to code written against the Collection API.

Read-only collections are **covariant** (List<out T>), mutable ones are **invariant** (MutableList<T>), and the reason comes straight from where T appears in each interface.

interface Source<out T> { fun next(): T }   // T only ever produced, an out position

open class Animal; class Cat : Animal()
val cats: List<Cat> = listOf(Cat())
val animals: List<Animal> = cats            // OK, List is covariant

List<T> only ever produces T, as a return value, so List<Cat> is safely usable wherever a List<Animal> is expected, you can only ever read a Cat back out of it as an Animal. But MutableList<T> also consumes T, through add(T), an in position. If covariance were allowed there, you could assign a MutableList<Cat> to a MutableList<Animal> reference and then call add(Dog()) on it, silently corrupting what's really a list of Cats. Keeping MutableList invariant is exactly what stops that at compile time.

One feature still finding its footing: **context parameters**. They let a function or property require some value to be implicitly available in scope, without it being a regular parameter or an extension receiver, useful for things like an implicit Logger or Transaction.

context(logger: Logger)
fun doWork() {
    logger.info("working")
}

This replaced the earlier **context receivers** design, -Xcontext-receivers, superseded by context parameters, -Xcontext-parameters, as of Kotlin 2.2. Know the status precisely: both are still experimental, not stable, so don't overclaim in an interview.

That's the throughline for this whole lesson. K2 and KSP are settled, production-ready foundations, and worth stating with full confidence. Collection variance and the read-only-versus-mutable-versus-truly-immutable distinctions are stable semantics you're expected to know cold, not hedge on. Context parameters are the frontier feature, describe them carefully, precisely, and without overselling how finished they are, and that calibration is itself part of what a good interview answer sounds like.

Back to Kotlin 2.x, KSP & Collections