Interview prep · Junior → Senior

Kotlin Interview Questions

The questions a Kotlin interviewer actually asks about the language — the type system, null safety (the load-bearing one), classes, data classes and objects, functions and lambdas, extensions and scope functions, generics and variance, collections and sequences, coroutines and Flow, Java interop, and a slice of Multiplatform — each answered with a short example and a link to the source. This page covers the Kotlin language and its observable semantics; for the JVM it targets see the Java version reference, and for the Android platform the Android version reference. Pair it with the Kotlin version reference and the open Kotlin roles on the jobs board.

Difficulty

Junior — expected of anyone writing Kotlin; the everyday syntax, val/var, and null-safety basics.
Mid — the language in practice: data classes, generics, scope functions, collections, and coroutine basics.
Senior — variance, structured concurrency and cancellation, Flow, interop, and the gotchas.
Difficulty

Showing 0 of 0 questions

1 · Basics & the type system

val vs var — and is val the same as immutable? Junior

var declares a reassignable variable; val declares one you can assign once. The trap is thinking val means the object is immutable — it doesn't. val makes the reference read-only, not the thing it points at. A val holding a MutableList can still have items added to it; you just can't repoint the name at a different list. Deep immutability is a property of the type (a List vs a MutableList, a data class with only vals), not of the val keyword.

val a = 1
// a = 2                     // error: val cannot be reassigned

val list = mutableListOf(1, 2)
list.add(3)                  // fine — the LIST is mutable; the reference is fixed
// list = mutableListOf()    // error: can't repoint the val

Why it's asked / follow-up: it separates “knows the keyword” from “knows what it guarantees.” Follow-up: “prefer val or var?” — default to val; reach for var only when you genuinely reassign. A val can even have a custom getter that returns a different value each read, so val is not a constant either — that's const val (compile-time constants only).

Source: Kotlin docs — Variables.

Does Kotlin's type inference mean it's dynamically typed? Junior

No. Kotlin is statically typed; inference just means the compiler works out a variable's type from its initializer so you don't have to write it. The type is fixed at compile time exactly as if you'd typed it — val n = 5 makes n an Int forever, and n = "x" is a compile error. Inference also works for expression-bodied function return types. You annotate explicitly when there's no initializer, when you want a wider type than inferred, or for public API clarity.

val n = 5                  // inferred Int
val name = "Ada"            // inferred String
val nums: List<Int> = listOf(1, 2) // explicit type widens/clarifies
fun square(x: Int) = x * x   // return type Int, inferred

Why it's asked / follow-up: it weeds out the “val/var is like JavaScript” misconception. Follow-up: “when should you write the type anyway?” — on public/API declarations (so a change to the body can't silently change the exposed type), and any time the inferred type isn't obvious to a reader.

Source: Kotlin docs — Basic types.

What are Any, Unit, and Nothing? Mid

Three types that anchor Kotlin's hierarchy. Any is the root of every non-nullable type (the analog of Java's Object, but with equals/hashCode/toString only). Unit is the type of a function that returns no useful value — it has exactly one value, also written Unit, and is what Java would call void, except it's a real type you can use as a generic argument. Nothing is the bottom type: it has no values and is the return type of a function that never returns normally (it always throws or loops forever). Nothing is a subtype of every type, which is why throw can appear on the right of an Elvis operator.

fun log(msg: String): Unit { println(msg) }   // Unit is implicit; can be omitted
fun fail(m: String): Nothing = throw IllegalStateException(m)

val name = user.name ?: fail("no name")  // Nothing fits any type → name is String

Why it's asked / follow-up: it checks whether you understand the type lattice, not just everyday syntax. Follow-up: “what's Nothing?” — the type whose only value is null; you'll see it inferred for val x = null.

Source: Kotlin docs — The Nothing type.

What's the difference between == and ===? Mid

== is structural equality: it compiles to a null-safe call to .equals(), so it compares contents. === is referential equality: it asks whether two references point at the exact same object. This is the reverse of Java, where == on objects is reference identity and you call .equals() for contents — a classic switching-cost for Java developers. For a data class, == works out of the box because the compiler generates a content-comparing equals.

val a = Point(1, 2)
val b = Point(1, 2)
a == b     // true  — structural (.equals): same contents
a === b    // false — referential: two distinct objects
a === a    // true

Why it's asked / follow-up: the Java-reversed semantics catch people, and it pairs with the boxed-value gotcha (see §11). Follow-up: “is == null-safe?” — yes; a == b desugars to a?.equals(b) ?: (b === null), so it never throws on a null receiver.

Source: Kotlin docs — Equality.

What is a smart cast, and when does it not apply? Mid

After you check a value's type with is (or check a nullable against null), the compiler automatically casts it within the scope where the check holds — no explicit cast needed. The catch: a smart cast is only sound if the value can't change between the check and the use. So it works on a val and on a local var, but not on a mutable property (var) of a class, an open property, or a property with a custom getter — because another thread or an overridden getter could return something different on the second read. The fix is to copy into a local val first.

fun len(x: Any): Int =
    if (x is String) x.length else 0   // x smart-cast to String

class Box(var item: String?) {
    fun show() {
        // if (item != null) item.length   // ERROR: var property can change
        val local = item
        if (local != null) println(local.length)  // ok: local val
    }
}

Why it's asked / follow-up: the “smart cast to X is impossible” compile error is a daily papercut, and knowing why shows you understand the soundness rule. Follow-up: “how do you force a cast?” — as (throws on failure) or as? (returns null on failure).

Source: Kotlin docs — Smart casts.

What are string templates and raw strings? Junior

A string template embeds a value directly in a string literal with $name for a simple variable or ${'$'}{expr} for an arbitrary expression — no + concatenation or String.format. A raw string is delimited by triple quotes: it spans multiple lines and does no escaping, so backslashes and quotes are literal (handy for regexes, paths, and JSON). Use .trimIndent() to strip the common leading whitespace of a raw string.

val name = "Ada"
println("Hi $name, len=${'$'}{name.length}")   // Hi Ada, len=3

val json = """
    { "user": "$name" }
""".trimIndent()               // no escaping needed

Why it's asked / follow-up: everyday syntax, but the $ vs ${'$'}{} distinction and raw-string escaping trip up beginners. Follow-up: “how do you put a literal $ in a template?” — use ${'$'}{'$'} (or escape it in a regular string).

Source: Kotlin docs — String templates.

2 · Null safety

How does Kotlin's null safety work — ?., ?:, and !!? Junior

Nullability is part of the type. String can never hold null; String? can. The compiler then forces you to handle the nullable case, which is how Kotlin eliminates most NullPointerExceptions at compile time. Three operators do the work: the safe call ?. returns null instead of dereferencing a null (so the whole expression is nullable); the Elvis ?: supplies a fallback when the left side is null; and the not-null assertion !! forces a non-null type and throws an NPE if it's wrong — the one escape hatch, to be used sparingly.

val name: String? = user.name
val len: Int? = name?.length          // safe call → Int? (null if name is null)
val size: Int = name?.length ?: 0      // Elvis → 0 when null
val forced: Int = name!!.length        // !! → throws NPE if name is null

Why it's asked / follow-up: null safety is the Kotlin feature; every candidate should command these three operators. Follow-up: “when is !! acceptable?” — only when you can prove non-null but the compiler can't (and even then a check with a clear error message is usually better). A codebase full of !! has thrown the null safety away.

Source: Kotlin docs — Null safety.

What is a platform type, and why is it dangerous? Senior

When Kotlin calls Java, the Java type has no nullability information (unless it carries @Nullable/@NotNull annotations). Kotlin represents such a value as a platform type, written String! in diagnostics. It's a deliberate escape from the null-safety rules: the compiler lets you treat it as either nullable or non-null and does not force a check. The danger is that if you assign it to a non-null Kotlin type and the Java side actually returned null, you get an NPE at that assignment — the exact runtime failure Kotlin was supposed to prevent, now hiding at the interop boundary.

// Java: public String getName() { return null; }
val n = javaObj.name        // platform type String! — no forced check
val safe: String? = javaObj.name  // treat as nullable — the safe choice
val bad:  String  = javaObj.name  // NPE here if Java returned null

Why it's asked / follow-up: it's the seam where Kotlin's guarantee leaks, and it separates people who've shipped Kotlin-on-Java from those who've only written pure Kotlin. Follow-up: “how do you defend against it?” — annotate the Java side (@Nullable/@NotNull, JSpecify), or always assign a platform type to an explicitly-nullable Kotlin type and handle the null.

Source: Kotlin docs — Null safety and platform types.

What is lateinit, and how does it differ from a nullable property? Mid

lateinit var lets you declare a non-null property without initializing it at construction — you promise to set it before first read. It's for dependency-injection and framework-lifecycle cases (an Android onCreate, a test @Before) where the value genuinely isn't available in the constructor. The alternative, a nullable var x: T? = null, forces a null check on every access even after you know it's set. The trade-off: lateinit gives you clean non-null access, but reading it before it's assigned throws UninitializedPropertyAccessException (not a plain NPE). It works only on var, non-null, non-primitive types, and you can test readiness with ::x.isInitialized.

class Service {
    lateinit var repo: Repo          // non-null, set later
    var cache: Cache? = null          // nullable alternative — checks on every use

    fun load() {
        if (::repo.isInitialized) repo.fetch()  // guard if unsure
    }
}

Why it's asked / follow-up: it's the everyday “non-null but not yet” tool, and the exception type is a common surprise. Follow-up: “can you lateinit an Int?” — no; primitives have no null sentinel, so lateinit is restricted to non-primitive types (use by Delegates.notNull() for that case).

Source: Kotlin docs — Late-initialized properties.

lateinit vs by lazy — when do you use each? Mid

Both defer initialization, but they answer different questions. by lazy is a delegate on a val: the initializer block runs on first access and the result is cached — you use it when the value is computed once, from within the object, and you want it immutable and thread-safe by default. lateinit is a var set from outside at some later point — you use it when a framework or injector supplies the value after construction and it might be reassigned. Rule of thumb: computed-here-and-fixed → lazy; injected-later → lateinit.

val config: Config by lazy { loadConfig() }  // computed once, on first read, cached
lateinit var presenter: Presenter         // injected by the framework later

Why it's asked / follow-up: both come up constantly and candidates conflate them. Follow-up: “is lazy thread-safe?” — by default LazyThreadSafetyMode.SYNCHRONIZED (locked so the initializer runs once); pass PUBLICATION or NONE to relax that when you don't need it.

Source: Kotlin docs — Lazy properties.

How do you run a block only when a nullable is non-null? Mid

The idiom is ?.let { ... }: the safe call skips the block entirely when the receiver is null, and inside the block the value is bound to it as a non-null local. It's the null-safe replacement for an if (x != null) guard, and it composes cleanly with Elvis to supply an else branch. Because it is a fresh local, it also sidesteps the smart-cast-on-var-property limitation from §1.

user.email?.let { email ->
    send(email)                 // runs only if email != null; email is non-null here
}

val shown = user.name?.let { "Hi ${'$'}it" } ?: "Hi guest"  // let + Elvis = if/else

Why it's asked / follow-up: it's the canonical “do something with a nullable” pattern and shows you know the scope functions from §5. Follow-up: “?.let vs if (x != null)?” — on a local val they're equivalent; ?.let wins when the receiver is a var property (no smart cast) or when you're already mid-chain and don't want to name an intermediate.

Source: Kotlin docs — let.

3 · Classes, data classes & objects

Primary vs secondary constructors and init blocks? Mid

The primary constructor is part of the class header (class C(val x: Int)); prefixing a parameter with val/var declares and assigns a property in one stroke. It has no body — setup code goes in init blocks, which run in declaration order, interleaved with property initializers, as part of primary construction. Secondary constructors (constructor(...)) are for extra construction shapes; each must delegate to the primary via : this(...). In practice most classes need only a primary constructor plus default arguments; secondaries are comparatively rare in idiomatic Kotlin.

class User(val name: String, val age: Int = 0) {
    val upper = name.uppercase()      // property initializer
    init { require(age >= 0) }         // runs during primary construction

    constructor(name: String) : this(name, 0)  // delegates to primary
}

Why it's asked / follow-up: the property-in-the-header syntax and the init-ordering rule are distinctly Kotlin. Follow-up: “what order do initializers and init blocks run in?” — top to bottom as written, before any secondary-constructor body.

Source: Kotlin docs — Constructors.

What does a data class generate — and what's the copy trap? Mid

For a data class the compiler synthesizes, from the primary-constructor properties, a content-based equals/hashCode, a readable toString, a copy(...) for non-destructive edits, and componentN() functions that enable destructuring. It's the idiomatic value-carrier (DTOs, domain values). The trap: copy is shallow. It copies the top-level fields but shares any referenced mutable objects, so mutating a nested list through the copy also affects the original. Two more: only primary-constructor properties count toward the generated members (a property in the body is ignored by equals), and a data class can't be abstract/open/sealed.

data class Cart(val id: Int, val items: MutableList<String>)

val a = Cart(1, mutableListOf("x"))
val b = a.copy(id = 2)         // new id, but items is the SAME list
b.items.add("y")               // a.items now ["x","y"] too — shallow copy

Why it's asked / follow-up: data classes are everywhere and the shallow-copy aliasing is a real production bug. Follow-up: “how do you get a deep copy?” — there's no built-in deep copy; copy the nested structures yourself (or use immutable collections so aliasing is harmless).

Source: Kotlin docs — Data classes.

What is an object, and is a companion object the same as static? Mid

An object declaration is a singleton: one instance, created lazily and thread-safely, that you reference by name. A companion object is a singleton tied to a class, giving you members you call on the class name (Factory.create()) — the Kotlin stand-in for Java static members. But it is not static: it's a real object instance that can implement interfaces, extend classes, hold state, and be passed as a value. That's why factory methods and constants land there. Genuinely static behavior (a member callable without initializing the companion, or interop that expects a static field/method) needs @JvmStatic or @JvmField.

object Registry { val items = mutableListOf<String>() }  // singleton

class User private constructor(val name: String) {
    companion object Factory {
        fun create(n: String) = User(n)     // User.create("Ada")
    }
}

Why it's asked / follow-up: “companion object == static” is the most common wrong answer, and the difference matters for interop and for implementing interfaces on the companion. Follow-up: “how do you call it from Java?” — User.Companion.create("Ada") unless you add @JvmStatic, which exposes it as a true static method.

Source: Kotlin docs — Companion objects.

What is a sealed class/interface, and how does it make when exhaustive? Senior

A sealed type has a closed set of subtypes, all known to the compiler at compile time (declared in the same module/package). That lets a when over a sealed type be exhaustive: cover every subtype and you need no else branch — and the day someone adds a new subtype, that when stops compiling until you handle it. This is the idiomatic way to model a fixed hierarchy of states or results (a UI state, a network outcome) with type-safe, compiler-checked handling. Sealed interfaces (stable since Kotlin 1.5) lift the single-parent restriction of sealed classes, so a type can belong to more than one sealed hierarchy.

sealed interface Result
data class Ok(val data: String) : Result
data class Err(val code: Int) : Result

fun render(r: Result) = when (r) {   // no else needed — exhaustive
    is Ok  -> show(r.data)
    is Err -> showError(r.code)
}

Why it's asked / follow-up: sealed + exhaustive when is the backbone of modeling state in modern Kotlin (and the Result/UiState pattern in Android). Follow-up: “sealed vs enum?” — an enum is a fixed set of singletons that all carry the same fields; a sealed hierarchy is a fixed set of types that can each carry different data (see the sealed interfaces feature in Kotlin 1.5).

Source: Kotlin docs — Sealed classes and interfaces.

What can a Kotlin enum class do beyond a list of constants? Junior

An enum class is a fixed set of named singletons, but each constant can carry constructor data and the enum can declare methods — including abstract methods that each constant overrides, giving per-constant behavior. Every enum gets name, ordinal, the synthetic values()/entries and valueOf(), and works as an exhaustive when subject. Prefer entries (a cached EntryList, stable since Kotlin 1.9) over values(), which allocates a fresh array on each call.

enum class Planet(val gravity: Double) {
    EARTH(9.81), MARS(3.71);
    fun weight(mass: Double) = mass * gravity
}
Planet.entries.forEach { println(it.name) }   // prefer entries over values()

Why it's asked / follow-up: it's an easy fluency check that also surfaces the entries-vs-values() modernization. Follow-up: “enum or sealed for a state machine?” — enum if each state is data-free and identical in shape; sealed if states carry different payloads (see the previous question).

Source: Kotlin docs — Enum classes.

What are the visibility modifiers, and what does internal mean? Junior

Four levels: public (the default — visible everywhere), private (the file for a top-level declaration, or the class for a member), protected (the class and its subclasses; not available for top-level declarations), and internal (visible everywhere in the same module). The distinctive one is internal: a module is a set of files compiled together (a Gradle source set, a Maven module, an IntelliJ module), so internal is how a library exposes something to its own code while keeping it out of consumers' reach — a boundary Java's package-private can't express.

class Api {
    private val secret = 42          // only inside Api
    internal val buildId = "dev"      // anywhere in this module
    protected open fun hook() {}       // this class + subclasses
    fun visible() {}                    // public — the default
}

Why it's asked / follow-up: a fluency check that surfaces the module-scoping concept most Java developers haven't met. Follow-up: “what's the default visibility?” — public (Java's default is package-private), so you narrow deliberately.

Source: Kotlin docs — Visibility modifiers.

4 · Functions & lambdas

What are default and named arguments, and why do they replace overloads? Junior

A parameter can declare a default value, so callers omit it; and any argument can be passed by name, so you can skip earlier defaults and make call sites self-documenting. Together they collapse the pile of telescoping overloads you'd write in Java into one function. Named arguments also let you pass arguments in any order and are the readable way to pass a bare boolean or several same-typed values.

fun greet(name: String, greeting: String = "Hello", loud: Boolean = false) = …

greet("Ada")                          // uses both defaults
greet("Ada", loud = true)             // skip greeting, name the boolean

Why it's asked / follow-up: it's the everyday reason Kotlin APIs have far fewer overloads than Java ones. Follow-up: “what about calling from Java?” — Java can't see Kotlin defaults; add @JvmOverloads to generate the overload set for Java callers (see §10).

Source: Kotlin docs — Default and named arguments.

What are higher-order functions and function types? Mid

Functions are first-class: a function type like (Int) -> String is a real type you can use as a parameter, return value, or variable, and a higher-order function is one that takes or returns another function. This is what powers the collection operators (map, filter) and lets you write your own control-flow-like abstractions. A function type can also have a receiver (StringBuilder.() -> Unit), which is the basis of Kotlin's type-safe builder DSLs.

fun apply3(x: Int, op: (Int) -> Int): Int = op(op(op(x)))

val inc: (Int) -> Int = { it + 1 }
apply3(0, inc)                // 3
apply3(0) { it + 10 }         // trailing lambda: 30

Why it's asked / follow-up: it underpins every functional API in the language. Follow-up: “how do you reference an existing function as a value?” — a function reference, ::isEven or String::length, which is a value of the matching function type.

Source: Kotlin docs — Higher-order functions.

What's the trailing-lambda convention and the implicit it? Junior

Two syntax rules that make Kotlin read the way it does. If a function's last parameter is a function, you can move the lambda outside the parentheses — and if that's the only argument, drop the parentheses entirely. And a single-parameter lambda doesn't have to name its parameter: the implicit it refers to it. Together they're why list.filter { it > 0 } reads like built-in syntax rather than a method call.

nums.filter { it > 0 }              // trailing lambda, only arg, implicit it
nums.fold(0) { acc, n -> acc + n }  // two params → must name them
runCatching { risky() }             // reads like a keyword

Why it's asked / follow-up: it's the syntactic backbone of idiomatic Kotlin and the reason the standard library feels like language. Follow-up: “when can't you use it?” — with more than one parameter, or when nesting lambdas where two its would collide; name them for clarity.

Source: Kotlin docs — Lambda expressions.

What are inline functions, and what do noinline/crossinline do? Senior

Marking a higher-order function inline tells the compiler to copy the function body and the lambda body directly into the call site, eliminating the function-object allocation and call overhead a lambda would otherwise cost. That's why the standard collection/scope functions are inline. Inlining also unlocks two things a normal lambda can't do: a non-local return (returning from the enclosing function out of the lambda) and reified type parameters (§6). noinline opts one lambda parameter out of inlining (so you can store or pass it on); crossinline keeps a lambda inlined but forbids non-local returns from it (needed when the lambda is invoked from another execution context).

inline fun measure(block: () -> Unit) {
    val t = System.nanoTime(); block(); println(System.nanoTime() - t)
}
measure { compute() }        // no lambda object allocated — body inlined

Why it's asked / follow-up: it's a real performance/semantics tool and the reason non-local returns and reified exist. Follow-up: “should you inline everything?” — no; inlining a large body bloats every call site. It pays off for small functions that take lambdas; a plain function without lambda parameters gains little and the compiler warns.

Source: Kotlin docs — Inline functions.

What is vararg, and what is the spread operator? Mid

A vararg parameter accepts any number of arguments, which the function sees as an array. To pass an existing array where a vararg is expected, you must spread it with the * operator — Kotlin won't silently treat the array as a single argument or auto-unpack it. A function can have only one vararg; parameters after it must be passed by name.

fun sum(vararg xs: Int) = xs.sum()

sum(1, 2, 3)                 // 6
val arr = intArrayOf(1, 2, 3)
sum(*arr)                    // spread the array — 6

Why it's asked / follow-up: the spread requirement surprises people migrating from Java's transparent varargs. Follow-up: “can you mix spread and literal args?” — yes: sum(0, *arr, 9) is fine.

Source: Kotlin docs — Varargs.

5 · Extensions & scope functions

What are extension functions and properties? Mid

An extension lets you add a function or read-only property to a type you don't own — String, List, a third-party class — and call it with dot syntax as if it were a member. It does not actually modify the class: the compiler resolves it to a static helper that takes the receiver as a hidden first argument, so it has no access to private members and adds no field to the type (an extension property can't have backing state, only a computed getter). Extensions are how the standard library keeps core types small while offering a rich API, and how you keep helper functions discoverable at the call site.

fun String.shout() = uppercase() + "!"       // extension function
val String.initials get() =                    // extension property (computed)
    split(" ").map { it.first() }.joinToString("")

"hi there".shout()      // "HI THERE!"

Why it's asked / follow-up: extensions are pervasive and the “it's just a static function” mental model is what prevents misuse. Follow-up: “can an extension property hold a value?” — no; no backing field, so it must be a computed get (and optional set backed by something else).

Source: Kotlin docs — Extensions.

Do extension functions dispatch virtually? What does “resolved statically” mean? Senior

No — extensions are dispatched statically, by the declared type of the receiver, not the runtime type. Which extension runs is decided at compile time from the static type of the expression you call it on, so extensions do not participate in polymorphism the way member functions do. Define an extension on Base and another on Derived, call through a Base-typed reference holding a Derived, and you get the Base one. Also: if a member and an extension have the same signature, the member always wins.

open class Base; class Derived : Base()
fun Base.who() = "base"
fun Derived.who() = "derived"

val b: Base = Derived()
b.who()      // "base" — picked by the DECLARED type, not runtime

Why it's asked / follow-up: it's the signature extension-function gotcha and a good depth separator. Follow-up: “so should you rely on extensions for overridable behavior?” — no; if you need polymorphism, use a real member function.

Source: Kotlin docs — Extensions are resolved statically.

let, run, with, apply, also — how do you choose? Mid

The five scope functions all run a block on an object; they differ on two axes: how the object is referenced inside the block (as it or as the receiver this) and what the call returns (the object itself, or the block's result). Get this table right and you'll pick correctly every time:

// fn      refers to obj as   returns          typical use
// let     it                 lambda result    transform / null-guard (?.let)
// run     this               lambda result    compute a value from a receiver
// with    this               lambda result    call many members on one object
// apply   this               the object       configure & return (builders)
// also    it                 the object       side effect (log/validate) in a chain

val file = File("a.txt").apply { createNewFile() }   // returns the File
val lines = file.let { readAll(it) }                  // returns the result

Why it's asked / follow-up: it's a signature Kotlin cluster and the single most-confused API in the language; the return-value distinction is the load-bearing part (see the §11 gotcha). Follow-up: “apply vs also?” — both return the receiver; apply exposes it as this (for configuration), also as it (for a side effect that reads it).

Source: Kotlin docs — Scope functions.

6 · Generics & variance

What are generics and constraints in Kotlin? Mid

Generics let a class or function take a type parameter so it works over many types while staying type-safe. An upper-bound constraint (<T : Comparable<T>>) restricts what can be substituted; the default bound is Any? (so a plain T is nullable-permitting). Multiple bounds go in a where clause. Like Java — and unlike C# — Kotlin generics run on the JVM with type erasure, so the type argument isn't available at runtime unless you make it reified (below).

fun <T : Comparable<T>> maxOf(a: T, b: T): T =
    if (a > b) a else b        // bound gives us > (compareTo)

class Box<T>(val value: T)   // generic class

Why it's asked / follow-up: it's the foundation the variance and reified questions build on. Follow-up: “how do you allow only non-null T?” — bound it with <T : Any>.

Source: Kotlin docs — Generics.

What is declaration-site variance — out and in? Senior

By default a generic type is invariant: Box<Dog> is not a Box<Animal> even though Dog is an Animal. Variance annotations relax that at the declaration. out T makes the parameter covariantT may only appear in out positions (return types), so a producer like List<out T> means List<Dog> is a List<Animal>. in T makes it contravariantT may only appear in in positions (parameters), so a consumer like Comparable<in T> means a Comparable<Animal> can stand in for a Comparable<Dog>. The mnemonic is PECS from Java: Producer out, Consumer in.

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

val dogs: Source<Dog> = …
val animals: Source<Animal> = dogs                   // ok because of `out`

Why it's asked / follow-up: variance is the classic senior generics question and the out/in syntax is cleaner than Java wildcards, so interviewers probe whether you understand the concept, not just the keyword. Follow-up: “why is List covariant but MutableList invariant?” — a read-only List only produces T; a MutableList also consumes it (add), so it can't be out.

Source: Kotlin docs — Declaration-site variance.

What are type projections and the star projection <*>? Senior

When a type is invariant at its declaration, you can still apply variance at the use site — a type projection. Array<out Any> as a parameter type says “I'll only read from this array,” which lets a caller pass Array<String>; the compiler then bans the write methods. The star projection Foo<*> means “a Foo of some specific but unknown type” — you can read values as the upper bound (often Any?) but can't pass anything in, because the compiler can't verify the element type. It's the Kotlin analog of Java's Foo<?>.

fun printAll(items: List<*>) {          // unknown element type
    for (x in items) println(x)          // x is Any? — read only
}
fun copy(from: Array<out Any>, to: Array<Any>) { … }  // use-site out

Why it's asked / follow-up: it's the “I have an invariant type but need flexibility here” tool, and the star projection's read-only-Any? behavior is easy to get wrong. Follow-up: “List<*> vs List<Any?>?” — List<Any?> accepts any element on read and you know it's exactly that type; List<*> is a list of some fixed unknown type you may only read as Any?.

Source: Kotlin docs — Type projections and star projections.

What is a reified type parameter, and why does it need inline? Senior

Because of JVM type erasure, a normal generic function can't reference its type argument at runtime — you can't write x is T or T::class. Marking the parameter reified in an inline function fixes this: since the function body is inlined at each call site, the compiler knows the concrete type there and substitutes it in, so is T, as T, and T::class all work. It requires inline precisely because inlining is what makes the concrete type available; a non-inline function has no call-site to substitute into.

inline fun <reified T> Gson.fromJson(json: String): T =
    fromJson(json, T::class.java)     // T::class works — reified

val user = gson.fromJson<User>(text)   // no need to pass User::class

Why it's asked / follow-up: it's the elegant answer to erasure and the reason so many Kotlin APIs read as foo<Type>() instead of foo(Type::class.java). Follow-up: “can you make a non-inline function reified?” — no; and you can't call a reified function reflectively for the same reason.

Source: Kotlin docs — Reified type parameters.

7 · Collections & sequences

Is a Kotlin List immutable? Mid

Not exactly — it's read-only, which is weaker. Kotlin splits collections into two interface hierarchies: List/Set/Map expose no mutators, and MutableList/etc. add them. But “read-only” is a property of the interface you hold, not a guarantee the underlying data can't change. A List can be a view onto a MutableList that someone else still mutates, and on the JVM a List can be cast back to its mutable implementation. So List means “you can't change it through this reference,” not “this is a deeply immutable, frozen collection.” For true immutability you need the kotlinx.collections.immutable library or a defensive copy.

val m = mutableListOf(1, 2)
val ro: List<Int> = m       // read-only VIEW of the same list
// ro.add(3)              // won't compile — no mutators on List
m.add(3)                   // but ro now reflects [1, 2, 3] — same backing list

Why it's asked / follow-up:List is immutable” is a common oversimplification that leads to aliasing bugs. Follow-up: “how do you defend an API boundary?” — return list.toList() (a copy), not the live mutable list, if callers must not observe later mutations.

Source: Kotlin docs — Collection types.

What are the common collection operators, and do they mutate? Junior

The standard library ships a rich set of functional operators — map, filter, fold/reduce, groupBy, associate, flatMap, sortedBy, partition, first/find — that read like a pipeline. On a plain collection they are eager and non-mutating: each returns a brand-new list rather than changing the receiver, so you chain them without side effects. (The mutating variants exist too, distinguished by name: sort() mutates in place, sorted() returns a copy.)

val names = people
    .filter { it.age >= 18 }
    .sortedBy { it.age }
    .map { it.name }                // each step returns a new list

val byCity = people.groupBy { it.city }  // Map<String, List<Person>>

Why it's asked / follow-up: it's the daily API and the eager/new-list semantics matter for performance (next question). Follow-up: “reduce vs fold?” — fold takes an explicit initial accumulator (and works on an empty collection); reduce uses the first element and throws on empty.

Source: Kotlin docs — Collection operations.

When should you use a Sequence instead of a List? Senior

Chained collection operators are eager: each step materializes a full intermediate list. A Sequence is lazy — elements flow through the whole pipeline one at a time, and terminal operations pull only what they need. That wins in two cases: a long or infinite source where you only want some elements (a find or take can short-circuit without processing the rest), and a multi-step pipeline over a large collection where the intermediate lists would be expensive. For small collections or a single operation, eager is usually faster (no per-element iterator overhead), so Sequence is not a blanket win.

val result = (1..1_000_000).asSequence()
    .map { it * 2 }
    .filter { it % 3 == 0 }
    .first()                        // stops after the first match — no full pass

generateSequence(1) { it + 1 }      // infinite; only lazy makes this usable
    .take(5).toList()

Why it's asked / follow-up: lazy-vs-eager is a real performance lever and a common “when would you reach for X” probe. Follow-up: “is a Sequence always faster?” — no; for small data or one step the eager path avoids the sequence's iterator overhead. Measure; don't cargo-cult .asSequence().

Source: Kotlin docs — Sequences.

8 · Coroutines

What is a suspend function, and does it run on a background thread? Mid

A suspend function is one that can pause and resume without blocking the underlying thread. When it hits a suspension point (a call to another suspending function that actually suspends), it can release the thread to do other work and resume later where it left off. Crucially, suspend by itself says nothing about which thread the code runs on — it's about not blocking, not about concurrency or offloading. A suspend function can only be called from another suspend function or from a coroutine builder. Under the hood the compiler rewrites it with a hidden Continuation parameter (continuation-passing style). Coroutines have been a stable part of the language since Kotlin 1.3 (October 2018).

suspend fun fetchUser(id: Int): User {
    val json = httpGet("/users/$id")   // suspends here; thread is free meanwhile
    return parse(json)
}
// fetchUser(1)   // won't compile outside a coroutine / suspend fn

Why it's asked / follow-up: “suspend means it runs on another thread” is the most common misconception; getting it right is the gate to the rest of coroutines. Follow-up: “so how do you move CPU work off the main thread?” — that's a dispatcher concern (withContext(Dispatchers.Default)), separate from suspend.

Source: Kotlin docs — Coroutines basics.

What is structured concurrency, and what does CoroutineScope guarantee? Senior

Structured concurrency means every coroutine is launched inside a CoroutineScope and becomes a child of it, forming a tree with three guarantees: a scope does not complete until all its children complete; cancelling a scope cancels all its children; and a child's failure propagates up (and, by default, cancels its siblings). The practical payoff is no leaked or orphaned coroutines — work can't outlive the scope that started it. The coroutineScope { } builder creates a scope that waits for everything inside it and only returns once all children are done.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { fetchUser() }        // both are children of this scope
    val feed = async { fetchFeed() }
    Dashboard(user.await(), feed.await())   // scope returns only when both finish
}                                            // if either throws, the other is cancelled

Why it's asked / follow-up: it's the design idea that makes coroutines safe, and the reason you rarely need manual lifecycle bookkeeping. Follow-up: “what's wrong with GlobalScope.launch?” — it's unstructured: the coroutine isn't tied to any lifecycle, so it can leak and outlive its caller. Prefer a scoped builder.

Source: Kotlin docs — Structured concurrency.

launch vs async/await — when do you use each? Mid

Both are coroutine builders; they differ in what they return. launch is fire-and-forget: it returns a Job (a handle for cancel/join) and is for work whose result you don't need — a side effect. async returns a Deferred<T> whose .await() gives you the result; it's for decomposing work into pieces you run concurrently and then combine. Both start immediately by default. The classic mistake is using async { }.await() back-to-back on one call — that's just a sequential suspend call with extra overhead; the win comes from starting several asyncs and awaiting them together.

scope.launch { logAnalytics(event) }         // side effect; returns a Job

val a = async { slowA() }                    // start both...
val b = async { slowB() }
val total = a.await() + b.await()             // ...then combine — concurrent

Why it's asked / follow-up: picking the wrong one either loses a result or serializes work that should be parallel. Follow-up: “what does CoroutineStart.LAZY change?” — the coroutine won't start until you start() it or (for async) call await().

Source: Kotlin docs — Concurrent using async.

What are dispatchers, and what does withContext do? Mid

A dispatcher decides which thread(s) a coroutine runs on. Dispatchers.Default is a CPU-bound pool sized to the cores (for computation); Dispatchers.IO is a larger, elastic pool for blocking I/O (file, network, JDBC); Dispatchers.Main is the platform UI thread (Android/Swing/JavaFX, via a platform artifact). withContext(dispatcher) { } is the idiomatic way to switch dispatchers for a block and return its result — the standard pattern for “do this blocking/heavy work off the main thread, then come back.” It suspends until the block completes; it doesn't create a new concurrent coroutine.

suspend fun loadName(): String =
    withContext(Dispatchers.IO) {          // run on the IO pool...
        db.query("SELECT name …")              // blocking call, safely off Main
    }                                          // ...result handed back to the caller's context

Why it's asked / follow-up: dispatcher choice is where correctness and performance meet (blocking on Main freezes the UI; blocking on Default starves computation). Follow-up: “Default vs IO?” — Default for CPU work (pool ≈ cores); IO for blocking calls (many more threads, since they mostly wait). They share threads under the hood but cap independently.

Source: Kotlin docs — Coroutine context and dispatchers.

Why is coroutine cancellation “cooperative,” and how can it fail? Senior

Cancelling a Job doesn't forcibly kill anything — it sets a flag. Cancellation is cooperative: a coroutine actually stops only when it hits a cancellation check, which every suspending function in kotlinx.coroutines performs (they throw CancellationException when the job is cancelled). So a coroutine doing a tight CPU loop that never suspends won't notice the cancellation and will run to completion. The fix is to make the loop cooperative: check isActive, call ensureActive(), or yield(). And because CancellationException is the normal cancellation signal, a blanket catch (e: Exception) that swallows it will break cancellation — rethrow it (or catch more narrowly).

val job = scope.launch {
    while (isActive) {              // cooperative — checks the cancel flag
        crunch()
    }
    // while (true) { crunch() }    // NON-cooperative: ignores cancellation
}
job.cancelAndJoin()                // request cancel, then wait for it to unwind

Why it's asked / follow-up: non-cooperative loops and swallowed CancellationException are two of the most common real coroutine bugs. Follow-up: “how do you run cleanup that must complete after cancellation?” — use try/finally, and wrap suspend calls in the finally with withContext(NonCancellable) so they aren't immediately cancelled too.

Source: Kotlin docs — Cancellation and timeouts.

How do exceptions propagate — coroutineScope vs supervisorScope? Senior

In a normal coroutineScope, a child failure is not isolated: it cancels the scope and all sibling coroutines, then rethrows out of the scope. Under a supervisorScope (or a scope with a SupervisorJob), failure propagates downward only — one child failing does not cancel its siblings, which is what you want for independent tasks (each request in a server, each item in a screen). The two builders also differ by builder: a launch propagates its exception to the parent immediately; an async defers the exception until you call await(). A CoroutineExceptionHandler is a last-resort catch for uncaught exceptions in root launch coroutines — it doesn't apply to async (catch that at await).

supervisorScope {
    launch { taskA() }             // if A throws, B still runs
    launch { taskB() }             // siblings are isolated under supervisor
}

try { deferred.await() }            // async exceptions surface at await()
catch (e: IOException) { … }

Why it's asked / follow-up: exception propagation is the subtlest part of coroutines and the source of “why did my whole scope die” bugs. Follow-up: “where does a CoroutineExceptionHandler go?” — in the context of the root coroutine/scope; installing it on a child launch has no effect because the child propagates to its parent first.

Source: Kotlin docs — Coroutine exceptions handling.

What is a Flow, and what does “cold” mean? Senior

A Flow<T> is an asynchronous stream of values built on suspending functions — the coroutine answer to producing many values over time (where a suspend fun returns one). A plain flow is cold: the builder block does nothing until a terminal operator collects it, and it re-runs from scratch for each collector. Intermediate operators (map, filter, onEach) are also cold and lazy — they just describe the pipeline; only collect (or toList, first, …) drives it. Backpressure is automatic: emit suspends until the collector is ready. Use flowOn to run the upstream on a different dispatcher without touching the collector's context.

fun ticks(): Flow<Int> = flow {
    var n = 0
    while (true) { emit(n++); delay(1000) }   // nothing runs until collected
}
ticks().map { it * 2 }.take(3)
    .collect { println(it) }         // terminal — starts the flow, cold per-collector

Why it's asked / follow-up: Flow is the async-stream primitive behind reactive Kotlin and Android state, and cold-vs-hot is the concept that unlocks the rest. Follow-up: “how is a cold Flow different from RxJava's Observable?” — conceptually similar, but Flow is built on suspension and structured concurrency, so cancellation and context flow through it naturally.

Source: Kotlin docs — Asynchronous Flow.

StateFlow vs SharedFlow — what's the difference? Senior

Both are hot flows — they exist and emit independent of collectors, and all collectors share the same emissions (unlike a cold flow). StateFlow models state: it always holds a current value, requires an initial value, is conflated (fast updates can skip intermediate values), and only emits when the value actually changes (it dedups with equals). A new collector immediately receives the latest value. SharedFlow models events: no required initial value, configurable replay and buffering, and it emits every value (no conflation/dedup unless you configure it). Rule of thumb: current-state-to-render → StateFlow; one-off events (navigation, snackbars) → SharedFlow. A StateFlow is essentially a SharedFlow with replay = 1, an initial value, and distinct-until-changed.

private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state       // always has a current value
_state.value = UiState.Ready(data)           // new collectors get the latest

private val _events = MutableSharedFlow<Event>()   // no initial value; every emit delivered

Why it's asked / follow-up: it's the modern-Android state-holder question and the conflation/dedup behavior trips people up. Follow-up: “why can dropping a repeated value bite you?” — StateFlow dedups by equals, so emitting a value equal to the current one is a no-op; if you need every emission (including duplicates), use a SharedFlow.

Source: Kotlin docs — StateFlow and SharedFlow.

9 · Functional & idiomatic Kotlin

How are if and when expressions, not just statements? Junior

In Kotlin if and when return values, so you assign or return them directly — which is why Kotlin has no ternary ?:-style conditional (if already is one). when is a more powerful switch: it matches values, ranges (in 1..9), types (is String), or arbitrary boolean conditions (a subject-less when), and as an expression it must be exhaustive (hence the else, unless the subject is a sealed type or enum that's fully covered). The last expression of each branch is its value.

val sign = if (n > 0) "+" else if (n < 0) "-" else "0"

val label = when {
    n > 0  -> "positive"
    n == 0 -> "zero"
    else   -> "negative"          // required — expression must be exhaustive
}

Why it's asked / follow-up: expression-orientation is core to idiomatic Kotlin and explains the missing ternary. Follow-up: “when is else not required?” — when the when subject is a sealed hierarchy or enum whose cases are all covered; the compiler then knows the branches are exhaustive.

Source: Kotlin docs — when expressions.

What are destructuring declarations, and what powers them? Mid

Destructuring unpacks an object into several variables at once: val (name, age) = person. It works for anything that provides component1(), component2(), … operator functions — which a data class generates automatically, and which Pair, Triple, and Map.Entry already have. The binding is positional, not by name, which is a subtle trap: if you reorder a data class's constructor properties, existing destructurings silently bind to the wrong values. Use _ to skip a component you don't need.

data class Person(val name: String, val age: Int)

val (name, age) = Person("Ada", 36)     // name="Ada", age=36 (positional!)
for ((key, value) in map) { … }          // Map.Entry destructuring
val (_, second) = pair                    // skip the first with _

Why it's asked / follow-up: it's a daily convenience whose positional (not name-based) nature is a genuine refactoring hazard. Follow-up: “can you destructure your own class?” — yes; declare operator fun componentN() functions (a data class does this for you).

Source: Kotlin docs — Destructuring declarations.

What does by do — class delegation and delegated properties? Senior

by expresses delegation, in two forms. Class delegation (class C(x: I) : I by x) implements an interface by forwarding every method to a wrapped instance — the language-level answer to “prefer composition over inheritance,” with the boilerplate generated for you. Delegated properties (val p by delegate) hand a property's get/set to a delegate object that implements getValue/setValue. The standard library ships the common ones: by lazy (compute once), Delegates.observable (fire a callback on change), Delegates.vetoable (approve or reject a change), and delegating a property to a Map (read the value by key).

class Logger(base: Appendable) : Appendable by base  // forwards to base

var name: String by Delegates.observable("") { _, old, new ->
    println("$old$new")                        // callback on every set
}

Why it's asked / follow-up: delegation is a distinctive Kotlin feature that shows up in both design (composition) and property mechanics; interviewers use it to probe depth. Follow-up: “how would you write a custom delegate?” — provide operator fun getValue(thisRef, property) (and setValue for a var); the ReadOnlyProperty/ReadWriteProperty interfaces formalize it.

Source: Kotlin docs — Delegated properties / Delegation.

What are operator overloading, infix, and tailrec? Mid

Three keywords that shape idiomatic call sites. Operator overloading: mark a function operator with a conventional name (plus, get, invoke, compareTo, contains) and Kotlin maps the symbol (+, [], (), <, in) to it — the mechanism behind list[0] and a in range. infix lets a single-argument member/extension be called without the dot and parentheses (1 to "a", x shl 2). tailrec asks the compiler to rewrite a tail-recursive function into a loop, avoiding a stack overflow — it only works if the recursive call is genuinely the last operation.

data class V(val x: Int) { operator fun plus(o: V) = V(x + o.x) }
V(1) + V(2)                     // V(3) — operator maps + to plus

infix fun Int.times(s: String) = s.repeat(this)
3 times "ab"                    // "ababab" — infix call

tailrec fun sum(n: Int, acc: Int = 0): Int =
    if (n == 0) acc else sum(n - 1, acc + n)   // compiled to a loop

Why it's asked / follow-up: these explain a lot of “how does that syntax work” magic in the standard library. Follow-up: “why did my tailrec not optimize?” — the recursive call wasn't in tail position (something happens after it, like 1 + rec(...)); the compiler warns when it can't apply the optimization.

Source: Kotlin docs — Operator overloading.

10 · Java interop & the JVM boundary

How does Kotlin call Java, and Java call Kotlin? Mid

Interop is a headline feature: Kotlin compiles to the same JVM bytecode, so the two languages share a classpath and call each other directly, no bridge. From Kotlin calling Java, Java types show up as usable Kotlin types, Java getters/setters appear as Kotlin properties (obj.name for getName()), and unannotated Java references become platform types (the nullability gap from §2). From Java calling Kotlin, most things map cleanly, but Kotlin-specific constructs need help: top-level functions land in a synthetic FileNameKt class, companion members are reached via .Companion, and default arguments/named args don't exist in Java — which is what the @Jvm* annotations (next question) address.

// Kotlin calling Java:
val list = ArrayList<String>()
list.size                    // Java getSize() seen as a property

// Java calling Kotlin top-level fun in Utils.kt:
// UtilsKt.helper();       // synthetic FileNameKt class

Why it's asked / follow-up: most Kotlin runs on a JVM with Java in the mix, so the boundary behavior is practical, not academic. Follow-up: “what's the biggest interop footgun?” — platform-type nullability: a Java method that can return null will NPE at the Kotlin assignment unless you treat it as nullable or annotate the Java side.

Source: Kotlin docs — Calling Java from Kotlin / Calling Kotlin from Java.

What do @JvmStatic, @JvmOverloads, @JvmField, and @JvmName do? Senior

These annotations shape the bytecode Kotlin emits so Java callers get an idiomatic API. @JvmStatic on a companion/object member exposes it as a real static method (so Java skips the .Companion hop). @JvmOverloads generates the telescoping overload set from a function with default arguments, since Java can't see Kotlin defaults. @JvmField exposes a property as a plain public field (no getter/setter) — useful for constants and interop with reflection-based frameworks. @JvmName renames the generated method/class, which also resolves the “same JVM signature after erasure” clash between two Kotlin functions that differ only by generic type.

class Api {
    companion object {
        @JvmStatic fun create() = Api()   // Api.create() from Java
    }
    @JvmOverloads fun greet(name: String, loud: Boolean = false) {}  // Java gets 2 overloads
}

Why it's asked / follow-up: anyone shipping a Kotlin library for Java consumers hits these; they show you understand what Kotlin actually compiles to. Follow-up: “when do you need @JvmName on a function?” — when two functions have the same name and erase to the same JVM signature (e.g. List<Int> vs List<String> parameters); renaming one fixes the platform clash.

Source: Kotlin docs — Calling Kotlin from Java.

What is a SAM conversion, and when does it apply? Mid

A SAM conversion lets you pass a lambda where a single-abstract-method interface is expected — Kotlin wraps the lambda into an instance of that interface. It applies automatically for Java interfaces (Runnable, Comparator, listeners), which is what makes calling Java callback APIs from Kotlin clean. For Kotlin-declared interfaces it does not happen automatically — you either use a Kotlin function type directly (usually preferable) or declare the interface fun interface (a functional interface), which opts it into SAM conversion.

// Java interface (Runnable) — SAM conversion is automatic:
executor.submit { doWork() }              // lambda → Runnable

fun interface Handler { fun handle(e: Event) }   // opt a Kotlin interface in
val h: Handler = Handler { log(it) }         // now a lambda works

Why it's asked / follow-up: it explains why lambdas “just work” for Java APIs but sometimes not for your own Kotlin interfaces. Follow-up: “Kotlin function type or fun interface?” — prefer a plain function type in new Kotlin code; reach for fun interface when you need a named type, a marker, or Java-side SAM ergonomics.

Source: Kotlin docs — Functional (SAM) interfaces.

Does Kotlin have checked exceptions? What is @Throws? Mid

No — Kotlin has no checked exceptions. Every exception is unchecked, so nothing forces a try/catch or a throws declaration; the language deliberately dropped the feature (the designers judged Java's checked exceptions caused more empty-catch boilerplate than safety). This matters at the Java boundary: because Kotlin emits no throws clause, a Java caller that needs one (to satisfy Java's checked-exception rules or a framework) won't see it — @Throws adds the declaration to the generated bytecode. Separately, a Kotlin function that returns nothing returns Unit, which maps to Java void.

@Throws(IOException::class)             // so Java sees `throws IOException`
fun readConfig(): String = File("c").readText()

fun log(m: String) { println(m) }        // returns Unit ⇒ void in Java

Why it's asked / follow-up: the “no checked exceptions” fact and its interop consequence are a common Java-to-Kotlin gotcha. Follow-up: “how should you signal recoverable failure without checked exceptions?” — model it in the return type (a sealed Result, Kotlin's Result<T>, or a nullable), so the caller must handle it — the type system does the job checked exceptions were meant to.

Source: Kotlin docs — Checked exceptions.

11 · Multiplatform & classic gotchas

What is Kotlin Multiplatform, and how do expect/actual work? Senior

Kotlin Multiplatform (KMP) lets you write shared logic once and compile it to several targets — JVM/Android, iOS/native, JS, Wasm — keeping the platform-specific pieces separate. The code lives in a common source set that must be platform-agnostic; where the common code needs something a platform provides, it declares an expect declaration (a signature with no body) and each platform supplies the matching actual implementation. The compiler enforces that every expect has an actual per target. It's how you share business logic, networking, and models while writing UI (or a few APIs like the current timestamp or platform name) natively per platform.

// commonMain:
expect fun platformName(): String

// androidMain:
actual fun platformName() = "Android ${'$'}{Build.VERSION.SDK_INT}"
// iosMain:
actual fun platformName() = UIDevice.currentDevice.systemName()

Why it's asked / follow-up: KMP dominates senior Kotlin/mobile interviews now that it's stable, and expect/actual is its defining mechanism. Follow-up: “is expect/actual the only way to abstract a platform?” — no; a plain interface in common code with per-platform implementations (dependency injection) is often preferred; expect/actual shines for a handful of low-level platform primitives. (KMP reached stable with Kotlin 1.9.20 in November 2023.)

Source: Kotlin docs — Expected and actual declarations.

What can Kotlin compile to, and how stable is each target? Mid

Kotlin has three backends: Kotlin/JVM (bytecode — the mature default, powering server-side and Android), Kotlin/Native (LLVM-compiled binaries for iOS, macOS, Linux, Windows, with no JVM), and Kotlin/JS (JavaScript), plus the newer Kotlin/Wasm. Their maturity differs and you should know the shape at a high level: JVM and Android are rock-solid; KMP for sharing logic across Android + iOS is stable (since late 2023); JS is stable; Wasm is the newest and still stabilizing. The Kotlin/Native memory model was reworked (the “new memory manager”) to remove the old strict-thread-ownership restrictions.

// A KMP module's Gradle targets (conceptual):
//   androidTarget()   → Kotlin/JVM  (stable)
//   iosArm64()        → Kotlin/Native (stable for KMP)
//   js(IR)            → Kotlin/JS   (stable)
//   wasmJs()          → Kotlin/Wasm (newer, stabilizing)

Why it's asked / follow-up: it checks that you understand Kotlin is not JVM-only and can speak to target trade-offs without needing build-tooling depth. Follow-up: “where does the per-target stability matrix live?” — the Kotlin version reference tracks it; see the Kotlin version reference's KMP stability matrix. (Build/tooling internals — the Gradle plugin, CocoaPods, source-set wiring — are out of scope here.)

Source: Kotlin docs — Kotlin Multiplatform.

Why did my scope-function chain return the wrong thing? Mid

The most common scope-function bug: mixing up which functions return the receiver and which return the lambda result (the §5 table). apply and also return the object they were called on; let, run, and with return whatever the block evaluates to. So apply when you meant to compute a value hands you the original object instead of the block's result, and let where you wanted to keep the receiver hands you the block's result instead. The symptom is a type mismatch or a stale value one step down the chain.

val a = StringBuilder().apply { append("hi") }   // StringBuilder (the receiver)
val b = StringBuilder().run  { append("hi"); toString() } // String (block result)
// val wrong: String = sb.apply { toString() }  // type error: apply returns sb

Why it's asked / follow-up: it's the practical consequence of the scope-function table and a genuine daily papercut. Follow-up: “quick rule?” — want the object back (to configure or side-effect) → apply/also; want a computed valuelet/run/with.

Source: Kotlin docs — Scope function selection.

Why does === on two equal Int? values sometimes disagree with ==? Senior

Because a nullable Int? is boxed to a java.lang.Integer on the JVM, and the platform caches small boxed integers (roughly -128..127). Within that range, two equal Int?s box to the same cached object, so === (referential) is true; outside it, they box to distinct objects, so === is false — even though == (structural, value-comparing) is true for both. The lesson: never use === to compare values; it's identity, and identity of boxed numbers is an implementation detail of the cache.

val a: Int? = 127; val b: Int? = 127
a == b     // true
a === b    // true  — cached (in -128..127)

val c: Int? = 128; val d: Int? = 128
c == d     // true
c === d    // false — separate boxes, outside the cache

Why it's asked / follow-up: it's the canonical “explain this output” puzzle and it ties together boxing, the Integer cache, and the ==/=== distinction from §1. Follow-up: “name another closure/capture gotcha” — capturing a mutable var in a lambda captures the variable, so later mutations are visible when the lambda runs (unlike Java's effectively-final capture); copy into a val if you want a snapshot.

Source: Kotlin docs — Referential equality.

Every answer links its primary source inline — the official Kotlin documentation and language reference, with the Kotlin language specification where an answer needs spec-level provenance and the coroutines guide for the coroutines/Flow answers. The questions are a curated set of the topics a Kotlin interviewer commonly covers, not a copy of any question bank. This page covers the Kotlin language; the JVM runtime it targets is the Java version reference's territory, and the Android framework layered on top is the Android version reference's. Feature-arrival details cross-link to the Kotlin version reference. Last updated July 2026.

Mungomash LLC · More on Kotlin