← All topics
Kotlin

Kotlin Language

Null safety, data classes, sealed types, generics, scope functions, delegation, and the language internals interviewers lean on.

41 questions ★ 17 high priority 16 junior21 mid4 senior

Kotlin is the foundation everything else is built on, and it shows up in every Android interview, sometimes as a warm-up and sometimes as a deeper discussion. Interviewers use it to tell apart people who write Kotlin from people who understand it.

A simple study path

Begin with null safety, val and var, data and sealed classes, collections, scope functions, and extension functions. Move to generics, inline functions, delegation, and DSL features later. You do not need to memorize every corner of the type system before writing good Kotlin.

What gets tested

  • Null safety - ?., ?:, !!, platform types from Java, and why lateinit exists.
  • Type system - val/var, data/sealed/enum classes, Any/Unit/Nothing, smart casts.
  • Functions & lambdas - higher-order functions, inline/noinline/crossinline, reified, scope functions.
  • Generics - declaration- vs use-site variance (in/out), star projection, type bounds.
  • Idioms - delegation (by), value class, DSLs with receiver lambdas, collection operators.
  • Output-based puzzles - boxing & the integer cache, init order, closures over loop variables, non-local returns.

How interviewers ask

Expect a mix of “explain the difference between X and Y” (e.g. lateinit vs lazy, List vs Sequence), “what’s the output of this snippet?”, and “how would you model this?” (sealed classes for UI state, value class for type-safe IDs). The strongest answers always reach the why and the trade-off, not just the definition.

Prep tip: for every concept here, write a 5-line snippet and explain it out loud. If you can’t teach it, you don’t know it yet.

Frequently asked. Prioritize these in your first pass.

Start here

Core ideas you should be able to explain in plain language.

Language essentials

val vs var vs const val - what's the difference?
Junior #kotlin#basics#immutability
  • var - a mutable (reassignable) variable.
  • val - a read-only reference. You can’t reassign it, but the object it points to may still be mutable (val list = mutableListOf(1) lets you list.add(2)).
  • const val - a compile-time constant. It’s inlined at the call site and must be a top-level or object/companion object member with a primitive or String value.
const val API_VERSION = "v1"        // compile-time, inlined
val createdAt = System.currentTimeMillis()  // runtime, just read-only
var counter = 0                      // mutable

Important distinction: val is about the reference being immutable, not deep immutability. const is resolved by the compiler, so it can’t hold anything computed at runtime.

Prefer val by default - it makes code easier to reason about and is required for things like smart casts on properties.

How does Kotlin's null safety work, and what does !! actually do?
Junior #kotlin#null-safety

Kotlin encodes nullability in the type system. String can never be null; String? can. The compiler forces you to handle the nullable case before you can dereference it, which eliminates most NullPointerExceptions at compile time.

The main tools:

  • Safe call ?. - returns null instead of throwing if the receiver is null: user?.name.
  • Elvis ?: - supply a fallback: user?.name ?: "Guest".
  • Smart casts - after a != null check, the compiler treats the variable as non-null inside that block.
  • !! (not-null assertion) - tells the compiler “trust me, this isn’t null.” If it is, it throws an NPE. It’s an escape hatch that throws away the guarantee you came for.
val length = name?.length ?: 0   // safe
val forcedLength = name!!.length // throws if name is null

Practical guidance: !! is a code smell - reserve it for genuine impossibilities, and prefer ?., ?:, requireNotNull() (which throws a meaningful message), or restructuring so the value can’t be null. Also mention platform types (String!) from Java interop: the compiler can’t verify them, so annotate Java APIs or null-check at the boundary.

Show some practical uses of the Elvis operator beyond a simple default.
Junior #kotlin#null-safety#elvis

The Elvis operator ?: returns its left side if non-null, otherwise the right side. Its power comes from the right side being able to be any expression - including return and throw (both have type Nothing).

// 1. Default value
val name = user?.name ?: "Guest"

// 2. Early return - "guard clause"
fun process(input: String?) {
    val text = input ?: return            // bail out if null
    println(text.length)                  // text is non-null here
}

// 3. Fail fast with a meaningful message
val config = loadConfig() ?: throw IllegalStateException("config missing")

// 4. Chained fallbacks
val displayName = nickname ?: fullName ?: email ?: "Anonymous"

// 5. Default for a whole expression
val count = map[key]?.size ?: 0

Why interviewers like #2 and #3: after val x = nullable ?: return, the compiler smart-casts x to non-null for the rest of the function - cleaner than nesting everything inside ?.let { } or an if (x != null) block.

What can the when expression do beyond a switch statement?
Junior #kotlin#when#control-flow

when is far more capable than Java’s switch. As an expression it returns a value, and it can branch on conditions, not just constants.

// As an expression with ranges, types, and multiple values
val label = when (score) {
    in 90..100 -> "A"
    in 70..89  -> "B"
    50, 51, 52 -> "borderline"
    else       -> "F"
}

// Type checks with smart cast
when (x) {
    is String -> x.length
    is List<*> -> x.size
    else -> 0
}

// No subject: acts like an if/else-if chain
when {
    user == null   -> showLogin()
    user.isAdmin   -> showAdmin()
    else           -> showHome()
}

Key abilities to mention:

  • Exhaustiveness - when used as an expression on a sealed type or enum, the compiler requires all cases (no else needed), and errors if you miss one later.
  • Smart casts inside is branches.
  • Ranges and collections with in.
  • Capturing the subject: when (val r = compute()) { ... }.

Interview note: prefer when as an expression returning a value over mutating a variable in branches - it’s more idiomatic and the exhaustiveness check protects you.

What are Kotlin's visibility modifiers? What does internal mean?
Junior #kotlin#visibility#modules

Four modifiers, with public as the default:

  • public (default) - visible everywhere.
  • private - visible only within the file (top-level) or the enclosing class.
  • protected - visible in the class and its subclasses (not top-level).
  • internal - visible everywhere in the same module.

The interesting one is internal, which Java doesn’t have. A module is a set of files compiled together - a Gradle module/source set, a Maven project, an IntelliJ module. internal is the backbone of modularization: a library module can expose a small public API while keeping implementation classes internal so other modules physically can’t depend on them.

internal class HttpClientImpl   // usable across this module, invisible outside it
class FeatureApi {
    private val client = HttpClientImpl()
}

Notes:

  • Kotlin has no package-private; internal (module) is the nearest equivalent and is broader than Java’s package scope.
  • internal names are mangled in the bytecode, which is why Java callers shouldn’t rely on them.
  • There’s no default “open” - classes/members are final unless marked open.

Classes and modeling

What does a data class generate for you, and what are its limitations?
Junior #kotlin#data-class

For the properties declared in the primary constructor, the compiler generates:

  • equals() / hashCode() - structural equality based on those properties
  • toString() - readable, e.g. User(id=1, name=Ada)
  • componentN() - enables destructuring (val (id, name) = user)
  • copy() - create a modified clone
data class User(val id: Int, val name: String)

val a = User(1, "Ada")
val b = a.copy(name = "Grace")   // User(id=1, name=Grace)
val (id, name) = b               // destructuring

Limitations / gotchas:

  • Only primary-constructor properties count toward equals/hashCode/toString. A property declared in the body is ignored by them.
  • A data class can’t be abstract, open, sealed, or inner.
  • The primary constructor needs at least one parameter, and they must all be val/var.
  • copy() does a shallow copy - nested mutable objects are shared.
data class Team(val members: MutableList<String>)
val first = Team(mutableListOf("Ada"))
val second = first.copy()
second.members += "Grace"
println(first.members) // [Ada, Grace]; both copies share the list

Common follow-up: “Two data classes with the same fields - are they equal?” No. equals also checks the runtime type, so different classes are never equal even with identical fields.

Why are Kotlin classes final by default? How do open, abstract, and interfaces differ?
Junior #kotlin#classes#inheritance#open

Kotlin classes and members are final by default: they cannot be inherited or overridden unless you explicitly allow it. This nudges code toward composition and makes extension points deliberate.

open class Vehicle {
    open fun move() = "moving"
    fun stop() = "stopped" // final; cannot be overridden
}

class Bike : Vehicle() {
    override fun move() = "pedalling"
}
  • open class: may be instantiated and subclassed. Only open members may be overridden.
  • abstract class: cannot be instantiated; may hold constructor state, implemented methods, and abstract members. Abstract members are implicitly open.
  • interface: defines a capability or contract. It can contain default method bodies and properties without backing fields, and a class may implement several interfaces.

Use an abstract class when related implementations need shared state or construction. Use interfaces for roles that unrelated types can implement. Prefer composition when you only want to reuse behavior because inheritance creates tighter coupling.

Common follow-up: an override member is open by default. Mark it final override when subclasses must not replace it again.

What is a companion object? Is it the same as Java's static?
Junior #kotlin#companion-object#static

Kotlin has no static. A companion object is a single object tied to a class that lets you call members on the class name:

class User private constructor(val id: Int) {
    companion object {
        const val TABLE = "users"
        fun create(id: Int) = User(id)   // factory
    }
}

User.create(1)      // looks static
User.TABLE

But it’s not the same as static - it’s a real object instance (User.Companion). That means it can:

  • implement interfaces and extend classes,
  • be passed as a value,
  • have extension functions.

Implications interviewers probe:

  • Members are not truly static on the JVM unless you add @JvmStatic (useful for Java callers) - otherwise Java sees User.Companion.create(...).
  • const val and @JvmField do compile to genuine static fields.
  • There’s one companion object per class, and it’s initialized when the class is first loaded - so it’s a handy place for factories and constants, but heavy work there delays class loading.

Common follow-up: “How do you make a singleton?” Use a top-level object Foo { }, not a companion - the companion belongs to a class, a top-level object stands alone.

Functions and idioms

How do default and named arguments work, and how do they replace the builder pattern?
Junior #kotlin#functions#default-args

Default arguments let a parameter have a fallback, so callers can omit it. Named arguments let callers pass parameters by name in any order, which makes calls readable and lets you skip optional ones in the middle.

fun showSnackbar(
    message: String,
    duration: Int = LENGTH_SHORT,
    actionLabel: String? = null,
    onAction: (() -> Unit)? = null,
) { /* ... */ }

// Call only what you need, by name:
showSnackbar("Saved")
showSnackbar("Undo delete", actionLabel = "Undo", onAction = { restore() })

Together they replace most builder patterns and telescoping overloads in Kotlin - no Builder class, no five overloaded constructors. One function with defaults covers it.

Interop gotchas:

  • Java callers don’t see Kotlin defaults. Add @JvmOverloads to generate overloads for them - essential when writing a custom View whose constructors Java/XML inflation calls.
  • Named arguments don’t work when calling Java methods (the parameter names aren’t reliably in the bytecode).
What are higher-order functions and function types in Kotlin?
Junior #kotlin#lambdas#functional

A higher-order function takes a function as a parameter and/or returns one. Functions are first-class values, with types like (Int) -> String or (T) -> Unit.

fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) if (predicate(item)) result.add(item)
    return result
}

val evens = listOf(1, 2, 3, 4).customFilter { it % 2 == 0 }

Worth knowing:

  • Trailing lambda syntax - if the last parameter is a function, you can move the lambda outside the parentheses: customFilter { it > 0 }.
  • it is the implicit name for a single-parameter lambda.
  • Function references - pass an existing function with ::: list.filter(::isValid).
  • A lambda is compiled to a Function object (allocation) unless the function is inline.

This is the backbone of the Kotlin stdlib (map, filter, forEach) and of idiomatic APIs like Compose and coroutine builders.

Explain the scope functions: let, run, with, apply, also. How do you choose?
Junior #kotlin#scope-functions#stdlib

They all execute a block on an object; they differ in how you reference the object (it vs this) and what they return (the object vs the lambda result).

FunctionReceiverReturnsTypical use
letitlambda resultnull-checks, transform a value
runthislambda resultrun a block + return a result
withthislambda resultgroup calls on one object (not an extension)
applythisthe objectconfigure/build an object
alsoitthe objectside effects (logging, validation)
// let - operate on a nullable, transform
val len = name?.let { it.trim().length } ?: 0

// apply - configure and return the same object
val paint = Paint().apply {
    color = Color.RED
    isAntiAlias = true
}

// also - side effect, pass through
val user = repo.load().also { Log.d("TAG", "loaded $it") }

How to choose (the mental model interviewers like):

  • Need the result of the block? → let / run / with.
  • Need the object back (chaining/config)? → apply / also.
  • Referencing members a lot? → this-receivers (run/with/apply) read cleaner.
  • Want an explicit name for clarity? → it-receivers (let/also).

apply for building, also for side effects, let for null-safe transforms are the three you’ll reach for most.

What do apply and let return?
Junior #kotlin#output-based#scope-functions
val a = StringBuilder("x").apply { append("y") }
val b = StringBuilder("x").let { it.append("y") }
val c = StringBuilder("x").let { it.append("y"); "done" }

println(a)
println(b)
println(c)

Output:

xy
xy
done

Why:

  • apply returns the receiver object (the StringBuilder). So a is the builder → "xy".
  • let returns the lambda result. For b, the last expression is it.append("y"), and StringBuilder.append returns the same StringBuilder - so b is also the builder → "xy".
  • For c, the lambda’s last expression is the string "done", so let returns "done".

The takeaway: apply/also always give you the object back; let/run/with give you whatever the block’s last line evaluates to. Here b only prints "xy" because append happens to return the builder - change the last line and let returns that instead.

What are extension functions, and how are they resolved?
Junior #kotlin#extensions#dispatch

An extension function doesn’t actually modify the class. The compiler turns it into a static method that takes the receiver as its first argument. So this:

fun String.shout() = uppercase() + "!"
"hi".shout()

compiles to roughly StringExtKt.shout("hi").

The crucial consequence: extensions are dispatched statically, by the declared type, not the runtime type. There’s no virtual dispatch / polymorphism.

open class A
class B : A()
fun A.name() = "A"
fun B.name() = "B"

val x: A = B()
println(x.name())   // "A"  - uses the static type A, not B

Other things to know:

  • A member function always wins over an extension with the same signature.
  • Extensions can’t access private/protected members of the receiver - they’re just outside static functions.
  • They’re great for keeping APIs focused and adding utilities to types you don’t own (Context, View, Flow), which is why Android codebases lean on them heavily.

Interview trap: the polymorphism question above. If you say it prints “B”, that’s the classic miss.

How do vararg and the spread operator work?
Junior #kotlin#functions#vararg

vararg lets a function accept a variable number of arguments; inside the function the parameter is an Array.

fun sum(vararg numbers: Int): Int = numbers.sum()
sum(1, 2, 3)        // pass any count
sum()               // or none

To pass an existing array where a vararg is expected, use the spread operator *, which unpacks the array into individual arguments:

val arr = intArrayOf(1, 2, 3)
sum(*arr)                       // spread
sum(0, *arr, 4)                 // can mix with other args

Points to know:

  • A function can have only one vararg parameter. If it’s not the last one, later parameters must be passed by name.
  • For reference types it’s an Array<out T>; for primitives use the specialized arrays (IntArray) to avoid boxing.
  • Spread copies the array’s references into the call, so it’s a shallow pass.

Real use: listOf(vararg elements: T), arrayOf(...), and forwarding args: fun log(vararg args: Any) = print(format(*args)).

Collections

List vs Sequence - what's the performance difference?
Junior #kotlin#collections#sequence#performance

The headline difference is eager vs lazy evaluation, but Sequence is not automatically faster.

  • On a List, each operation (map, filter, …) is processed fully and creates a new intermediate list before the next operation runs. It’s horizontal: do all the maps, then all the filters.
  • On a Sequence, elements flow through the whole chain one at a time, lazily, with no intermediate collections. It’s vertical: each element goes through map → filter → … until a terminal operation pulls it.
// List: builds a full mapped list of a million items, then filters it
val r1 = (1..1_000_000).map { it * 2 }.filter { it % 3 == 0 }.first()

// Sequence: pulls elements until first match - barely any work
val r2 = (1..1_000_000).asSequence().map { it * 2 }.filter { it % 3 == 0 }.first()

Use ordinary collection operations for small inputs or a single transformation: they are simple and often faster because sequences add iterator/lambda overhead. Reach for Sequence when you have a large input, several intermediate operations, or a short-circuiting terminal operation such as first, take, or any. Measure hot paths instead of treating laziness as a universal optimization.

When sequences win: large collections, multiple chained operations, or short-circuiting terminals (first, take, find) - you avoid allocating big intermediate lists and can stop early.

When lists win: small collections or a single operation. Sequences add per-element overhead (an iterator hop per stage), so for small data the simpler List is actually faster.

Error handling

How does Kotlin handle exceptions differently from Java?
Junior #kotlin#exceptions#error-handling

The headline difference: Kotlin has no checked exceptions. Every exception is unchecked, so you’re never forced to try/catch or declare throws. This removes Java’s boilerplate but means the compiler won’t remind you an API can fail - you have to know.

// No "throws IOException" needed; caller isn't forced to handle it
fun readConfig(): String = File("config").readText()

Other points:

  • try is an expression - it returns a value:
    val n = try { input.toInt() } catch (e: NumberFormatException) { 0 }
  • @Throws - annotate a function so Java callers see the checked exception (needed for interop, e.g. a function Java code must catch).
  • runCatching wraps a block in a Result<T>, turning exceptions into values for functional handling:
    val result = runCatching { api.fetch() }
        .map { it.body }
        .getOrElse { fallback }
  • Nothing is the type of throw, which is why it slots into any expression (val x = a ?: throw ...).

Caution interviewers like to hear: don’t catch broad Exception around coroutine code - it can swallow CancellationException and break structured cancellation. Rethrow it, or catch specific types.

Use it in practice

Common implementation choices, debugging, and trade-offs.

Language essentials

Explain Unit, Nothing, and Any. How do they differ?
Mid #kotlin#type-system

These sit at the edges of Kotlin’s type hierarchy.

Any - the root of all non-nullable types (like Java’s Object). Everything is an Any; Any? is the absolute top including null. It declares equals, hashCode, toString.

Unit - the type of functions that return “nothing meaningful,” equivalent to void, except Unit is a real type with a single value (Unit). That matters because generics need an actual type: Callback<Unit> works, Callback<void> couldn’t.

Nothing - the bottom type: it has no instances and is a subtype of every type. A function returning Nothing never returns normally - it always throws or loops forever.

fun fail(msg: String): Nothing = throw IllegalStateException(msg)

val name = user.name ?: fail("no name")  // compiler knows name is non-null after

Because Nothing is a subtype of everything, the compiler uses it for control flow: throw and TODO() have type Nothing, so they fit into any expression. emptyList() returns List<Nothing>, which is assignable to List<anything>.

Summary: Any = top (every value), Nothing = bottom (no value), Unit = “returns, but no useful value.”

When should you use == or === in Kotlin?
Mid #kotlin#output-based#equality
val a: Int? = 127
val b: Int? = 127
val c: Int? = 128
val d: Int? = 128

println(a == b)    // ?
println(a === b)   // ?
println(c == d)    // ?
println(c === d)   // ?

Output:

true
true
true
false

Why:

  • == calls equals()structural equality. All four comparisons by value are true.
  • === is referential equality (same object).
  • The Int? types are boxed (Integer). The JVM caches boxed integers in the range −128..127, so a and b point to the same cached object → a === b is true. 128 is outside the cache, so c and d are different boxed objects → c === d is false.

never use === to compare values - it’s an implementation detail of boxing. Use == for value equality. (With non-nullable Int, there’s no boxing and this trap disappears - it only shows up because the types are nullable, forcing boxing.)

What are smart casts, and when do they fail to apply?
Mid #kotlin#smart-cast#null-safety

A smart cast is the compiler automatically casting a value after you’ve checked its type or nullability, so you don’t write an explicit cast:

fun describe(x: Any) {
    if (x is String) {
        println(x.length)   // x smart-cast to String here
    }
}

It works after is checks, != null checks, and on the matched branch of a when.

When it fails - the classic interview point: the compiler only smart-casts if it can guarantee the value didn’t change between the check and the use. So it fails for:

  • var properties (especially of another class / open): they could be modified by another thread or an overridden getter between check and use.
  • Custom getters: a val with a custom getter could return a different value each call.
  • Properties from another module / mutable var globals.
class Holder { var name: String? = null }
fun f(h: Holder) {
    if (h.name != null) {
        // println(h.name.length)  // ERROR: smart cast impossible (mutable var)
    }
}

Fixes: copy to a local val first (val n = h.name; if (n != null) n.length), or use ?.let { }. Local vals and immutable properties smart-cast cleanly.

Classes and modeling

Sealed class vs enum vs abstract class - when do you use each?
Mid #kotlin#sealed-class#enum#state

All three model a restricted set of types, but at different levels.

  • enum - a fixed set of singleton instances, each the same type. Use it for a closed set of constants (Direction.NORTH). Every entry is one object; they can’t carry per-instance varying state across many instances.
  • sealed class / sealed interface - a restricted hierarchy of subclasses known at compile time, but each subtype can have its own properties and multiple instances. Perfect for modeling UI state or results.
  • abstract class - an open hierarchy; subclasses can be defined anywhere, including other modules. Use when you don’t need exhaustiveness and want open extension.
sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<Item>) : UiState
    data class Error(val message: String) : UiState
}

The big win for sealed is exhaustive when - the compiler knows all subtypes, so you don’t need an else and it errors if you add a case and forget to handle it:

when (state) {
    UiState.Loading    -> showSpinner()
    is UiState.Success -> render(state.items)
    is UiState.Error   -> showError(state.message)
}  // no else needed

Rule of thumb: closed set of plain constants → enum; closed set of variants that carry different datasealed; open extension → abstract.

lateinit vs lazy - what's the difference and when do you use each?
Mid #kotlin#lateinit#lazy#initialization

Both defer initialization, but they’re for different situations.

lateinit var

  • A var you promise to set before first use. No initial value.
  • Only for non-null, non-primitive types (var x: Int won’t work).
  • Accessing it before assignment throws UninitializedPropertyException.
  • You can reassign it and check ::x.isInitialized.
  • Use when something injects/sets the value later - Dagger fields, onCreate views/binding, test setup.
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
    binding = ActivityMainBinding.inflate(layoutInflater)
}

by lazy

  • A val computed once, on first access, then cached.
  • Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED); you can relax it.
  • Use for expensive, read-only values you might not even need.
val database by lazy { Room.databaseBuilder(...).build() }

Quick decision: mutable + set-externally-later → lateinit; read-only + compute-on-demand → lazy. And remember lateinit can’t be used with primitives or nullable types, while lazy can hold anything.

What are the uses of the object keyword in Kotlin?
Mid #kotlin#object#singleton

object creates a class and its single instance at once. It has three uses:

1. Singleton (object declaration)

object Analytics {
    fun track(event: String) { /* ... */ }
}
Analytics.track("open")   // thread-safe, lazily created on first access

2. Companion object - a singleton tied to a class, called via the class name (factories, constants).

3. Object expression (anonymous object) - Kotlin’s answer to anonymous classes:

view.setOnClickListener(object : View.OnClickListener {
    override fun onClick(v: View?) { /* ... */ }
})

// or an ad-hoc object holding state
val point = object {
    val x = 1
    val y = 2
}

Things to know:

  • An object declaration is initialized lazily and thread-safely on first use.
  • Unlike a class, you can’t have a constructor (it takes no parameters).
  • An anonymous object’s type is only visible locally - if returned from a public function it’s seen as its supertype.

Android note: an object singleton holding a Context is a classic memory leak - store applicationContext, never an Activity.

What is a backing field, and when is one generated? (the `field` keyword)
Mid #kotlin#properties#backing-field

A Kotlin property is really a getter (and setter), not a raw field. A backing field - referenced as field inside the accessor - is the actual storage, and the compiler generates it only when needed: when you use the default accessor, or you reference field in a custom one.

var counter: Int = 0
    set(value) {
        if (value >= 0) field = value   // `field` = the backing field
    }

// Computed property: NO backing field - just a getter
val isEmpty: Boolean
    get() = size == 0

Key points:

  • Using field avoids infinite recursion. Writing set(value) { counter = value } would call the setter again forever - field = value writes storage directly.
  • A property with only a custom getter and no field reference stores nothing - it’s computed each call.
  • A common pattern is the private mutable / public read-only pair (no custom field needed):
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state   // expose read-only view
How does destructuring work, and what is the componentN convention?
Mid #kotlin#destructuring

Destructuring unpacks an object into multiple variables. It works by calling component1(), component2(), … operator functions in order.

val (id, name) = user        // user.component1(), user.component2()
val (key, value) = mapEntry  // Map.Entry has component1/2
for ((index, item) in list.withIndex()) { /* ... */ }

data class generates componentN() for its primary-constructor properties automatically. Any class can support it by declaring them manually:

class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}

Gotchas worth raising:

  • Destructuring is positional, not by name - val (name, id) = user silently assigns name = user.id if you get the order wrong. This is a real bug source; reordering data-class properties can break callers.
  • Use _ to skip a component: val (_, name) = user.
  • Works in lambda parameters too: map.forEach { (k, v) -> ... }.
How does delegation with the by keyword work in Kotlin?
Mid #kotlin#delegation#by

by lets one object hand off work to another, with compiler-generated plumbing. Two flavors:

1. Class delegation - implement an interface by forwarding to an instance, instead of inheritance.

interface Repo { fun load(): String }
class NetworkRepo : Repo { override fun load() = "net" }

// CachingRepo implements Repo by delegating to `delegate`,
// overriding only what it needs.
class CachingRepo(delegate: Repo) : Repo by delegate {
    override fun load() = cache ?: super.load()  // override selectively
}

This is composition over inheritance, with no boilerplate forwarding methods.

2. Property delegation - a property’s get/set is delegated to an object that provides getValue/setValue.

val lazyValue: String by lazy { compute() }          // stdlib delegate
var name: String by Delegates.observable("") { _, old, new -> log(old, new) }
val token: String by preferences                      // custom delegate

Built-in delegates: lazy, Delegates.observable/vetoable, Delegates.notNull, and map-backed properties (val name: String by map). Compose’s by remember { mutableStateOf(...) } is property delegation too.

Write your own by implementing operator fun getValue(thisRef, property) (and setValue for var), or ReadOnlyProperty/ReadWriteProperty. Great for things like SharedPreferences-backed properties.

What is a value class (inline class) and when would you use it?
Mid #kotlin#value-class#performance

A value class (formerly inline class) wraps a single value to give it a distinct type, but the compiler inlines the underlying value at runtime - so you get type safety with (usually) no allocation overhead.

@JvmInline
value class UserId(val value: String)
@JvmInline
value class Email(val value: String)

fun fetch(id: UserId) { /* ... */ }

fetch(UserId("u123"))   // type-safe
// fetch(Email("a@b.c")) // compile error - can't mix them up

At runtime UserId is represented as a plain String wherever possible - no wrapper object is created.

Why use it:

  • Prevent primitive obsession / mix-ups - a function taking UserId, Email, and Meters can’t have its arguments swapped, unlike three Strings.
  • Domain modeling without the cost of a real wrapper class.

Rules & caveats:

  • Exactly one property in the primary constructor.
  • Can have methods and computed properties, but no init backing fields beyond that one value.
  • It gets boxed (allocated) when used as a nullable, as a generic type argument, or where its supertype is expected - so the “zero-cost” benefit isn’t guaranteed in every position.
What is a typealias, and how does it differ from a value class?
Mid #kotlin#typealias#value-class

A typealias gives an existing type a new name. It introduces no new type - it’s a pure compile-time alias, fully interchangeable with the original.

typealias UserId = String
typealias ClickHandler = (View) -> Unit
typealias Headers = Map<String, List<String>>

fun fetch(id: UserId) { }
fetch("u123")                 // a plain String works - same type

Use it to shorten verbose generic/function types and improve readability.

The crucial contrast with value class:

typealias UserId = Stringvalue class UserId(val v: String)
New distinct type?NoYes
Type-safe (prevents mixups)?NoYes
Runtime costNoneNone (usually inlined)
typealias Email = String
typealias Name = String
fun send(to: Email, name: Name) {}
send(userName, userEmail)     // compiles! aliases don't stop the swap

So: reach for typealias purely for readability of complex types; reach for value class when you need the compiler to enforce that two same-underlying types can’t be confused.

Functions and idioms

What is a functional interface in Kotlin?
Mid #kotlin#fun-interface#sam#interop

A functional interface has exactly one abstract method (Single Abstract Method). Mark it fun interface and Kotlin lets you implement it with a lambda instead of an object expression - that substitution is SAM conversion.

fun interface IntPredicate {
    fun accept(i: Int): Boolean
}

// SAM conversion: lambda becomes an IntPredicate
val isEven = IntPredicate { it % 2 == 0 }
isEven.accept(4)   // true

Without fun interface you’d have to write:

val isEven = object : IntPredicate {
    override fun accept(i: Int) = i % 2 == 0
}

Key points:

  • SAM conversion works automatically for Java interfaces (Runnable, OnClickListener, Comparator) - that’s why view.setOnClickListener { } works.
  • For Kotlin interfaces it only kicks in with the fun interface keyword; otherwise the compiler prefers you use a function type ((Int) -> Boolean) directly.
  • A fun interface can have other non-abstract (default) members, just one abstract one.

When to prefer fun interface over a typealias for a function type: when you want a named type with possible default methods, nominal typing, or Java interop - a plain (Int) -> Boolean is structural and can’t carry extra members.

What are infix functions and tailrec functions?
Mid #kotlin#infix#tailrec

Infix functions can be called without the dot and parentheses, reading like an operator. Requirements: marked infix, a member or extension, exactly one parameter (no default, no vararg).

infix fun Int.times(str: String) = str.repeat(this)
3 times "ab"        // "ababab"  (same as 3.times("ab"))

You already use stdlib infix functions: to ("key" to 1 builds a Pair), until, downTo, step, and/or, shl.

tailrec functions - when a recursive function’s recursive call is the very last operation (tail position), tailrec lets the compiler rewrite it into a loop, avoiding stack growth and StackOverflowError.

tailrec fun factorial(n: Long, acc: Long = 1): Long =
    if (n <= 1) acc else factorial(n - 1, acc * n)   // tail call → compiled to a loop

The catch: the recursive call must be the last action - return 1 + factorial(...) is not tail-recursive (the addition happens after), and the compiler warns. The accumulator-parameter trick (as above) is the usual way to make a function tail-recursive.

How does operator overloading work in Kotlin?
Mid #kotlin#operators

You overload an operator by defining a function with a reserved name and the operator modifier. Kotlin maps symbols to these functions:

OperatorFunction
a + ba.plus(b)
a[i]a.get(i)
a[i] = va.set(i, v)
a in bb.contains(a)
a..ba.rangeTo(b)
a == ba.equals(b)
+a / -aunaryPlus / unaryMinus
a()a.invoke()
data class Vec(val x: Int, val y: Int) {
    operator fun plus(o: Vec) = Vec(x + o.x, y + o.y)
    operator fun get(i: Int) = if (i == 0) x else y
}

val v = Vec(1, 2) + Vec(3, 4)   // Vec(4, 6)
val first = v[0]                 // 4

Things interviewers check you know:

  • The operator function name and signature are fixed - you can’t invent new symbols.
  • == always routes through equals (with a null check), and === (referential) can’t be overloaded.
  • Overloading invoke is how MutableState-like or DSL objects become “callable.”

Use it sparingly - only when the operator’s meaning is obvious (vectors, money, durations). kotlin.time.Duration (1.hours + 30.minutes) is a good example of tasteful use.

What are inline functions, and what do noinline and crossinline do?
Mid #kotlin#inline#performance#lambdas

inline tells the compiler to copy the function body - and its lambda arguments - into the call site instead of creating a function object for each lambda. For higher-order functions this removes the per-call lambda allocation and the extra invoke() call.

inline fun measure(block: () -> Unit) {
    val start = System.nanoTime()
    block()                       // body inlined, no Function object created
    Log.d("perf", "${System.nanoTime() - start}ns")
}

Two extra benefits unlocked by inlining:

  • Non-local returns - a return inside the lambda can return from the enclosing function.
  • reified type parameters - the real type is available at runtime (covered separately).

The modifiers:

  • noinline - opt a specific lambda out of inlining (e.g. you need to store it in a variable or pass it on as an object).
  • crossinline - keep the lambda inlined but forbid non-local returns, needed when the lambda is called from another execution context (like inside a Runnable/another lambda).
inline fun run(crossinline body: () -> Unit) {
    val r = Runnable { body() }   // crossinline required here
    r.run()
}

When NOT to inline: large function bodies (inlining bloats bytecode at every call site) or functions with no lambda parameters (little benefit). Use it for small higher-order utilities.

What is a reified type parameter and why do you need inline for it?
Mid #kotlin#generics#reified#inline

On the JVM generics are erased - at runtime List<String> and List<Int> are both just List, and a normal generic function can’t ask T::class or do is T. A reified type parameter keeps the concrete type available at runtime.

It only works with inline functions: because the function is inlined at the call site, the compiler substitutes the real type there, so the type information survives.

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

inline fun <reified T> List<*>.filterIsType(): List<T> =
    filterIsInstance<T>()        // uses `is T` under the hood

// Android: a clean startActivity helper
inline fun <reified T : Activity> Context.start() =
    startActivity(Intent(this, T::class.java))

context.start<DetailActivity>()

Why it matters: it removes the need to pass Class<T> parameters around (fromJson(json, Foo::class.java) becomes fromJson<Foo>(json)), which is why Gson/Moshi extensions, DI lookups, and intent builders use it everywhere.

Limitation to mention: because it relies on inlining, a reified type can’t be used from Java, and you can’t call it where T is itself a non-reified generic.

Collections

Which Kotlin collection operations do you use most often?
Mid #kotlin#collections#functional

These are bread-and-butter and come up constantly:

val nums = listOf(1, 2, 3, 4, 5)

nums.map { it * 2 }              // [2,4,6,8,10] - transform each
nums.filter { it % 2 == 0 }     // [2,4]        - keep matching
nums.reduce { acc, n -> acc + n } // 15         - combine, seed = first element
nums.fold(100) { acc, n -> acc + n } // 115     - combine with explicit seed

val words = listOf("apple", "avocado", "banana")
words.groupBy { it.first() }    // {a=[apple, avocado], b=[banana]}
words.associate { it to it.length } // {apple=5, avocado=7, banana=6}
words.associateBy { it.first() } // {a=avocado, b=banana} (last wins per key)
words.partition { it.length > 5 } // Pair([avocado, banana], [apple])

listOf(listOf(1,2), listOf(3)).flatMap { it } // [1,2,3] - map then flatten

Distinctions interviewers probe:

  • fold vs reduce - reduce starts from the first element and throws on an empty list; fold takes an explicit initial accumulator (and can change the result type).
  • associate vs associateBy vs groupBy - associate builds key→value pairs you specify; associateBy keys by a selector (one value per key, last wins); groupBy keys to a list of all matching values.
  • map vs flatMap - flatMap is for when each element produces a collection you want flattened into one.
  • mapNotNull / filterNotNull - transform-and-drop-nulls in one pass.

For big chains, prepend .asSequence() to avoid intermediate lists.

Is Kotlin's List truly immutable? Read-only vs immutable collections.
Mid #kotlin#collections#immutability

No - List is read-only, not immutable. The List interface simply doesn’t expose mutating methods like add/remove; it doesn’t guarantee the underlying data can’t change.

Two ways that bites you:

1. The same object can be referenced as both types.

val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable   // same backing object
mutable.add(4)
println(readOnly)   // [1, 2, 3, 4]  - it changed under you

2. A List can be cast back (it’s often an ArrayList at runtime).

So List protects your code from calling mutators, but it’s not a deep immutability guarantee.

For real immutability, use the kotlinx.collections.immutable library: ImmutableList / persistentListOf(). These genuinely can’t be mutated and are also recognized as stable by the Compose compiler, which helps skip recomposition.

val items: ImmutableList<Item> = persistentListOf(a, b, c)

Type system and generics

Explain generics variance: in, out, and star projection.
Mid #kotlin#generics#variance

Start with a practical rule: if a generic type only produces values, mark it out. If it only consumes values, mark it in.

These rules are called variance. Without in or out, a generic type is invariant: Box<String> cannot be used as Box<Any>, even though String is a subtype of Any.

out means producer. The type is returned, not accepted as input. This is why Kotlin’s read-only List is List<out T>.

interface Producer<out T> { fun produce(): T }
val p: Producer<Any> = object : Producer<String> { ... }  // OK

Kotlin’s read-only List<out E> is covariant - that’s why List<String> is usable as List<Any>.

in means consumer. The type is accepted as input, not returned. A Comparator<Any> can compare strings, so it can be used where a Comparator<String> is required.

interface Consumer<in T> { fun consume(item: T) }
val c: Consumer<String> = object : Consumer<Any> { ... }  // OK

Mnemonic: PECS / “in–consumer, out–producer.”

Star projection <*> - used when you don’t know or care about the argument: a Box<*> is a Box of some type. You can read values as the upper bound (Any?) but can’t safely write (except null), because the real type is unknown.

fun printAll(box: Box<*>) { println(box.get()) }  // get is fine, set isn't

in/out at the declaration site is declaration-site variance; specifying it at a usage point (Array<out T>) is use-site variance (Kotlin’s equivalent of Java wildcards).

Java interoperability

What Kotlin–Java interoperability issues and JVM annotations matter in Android code?
Mid #kotlin#java#interop#jvm

Kotlin and Java call each other directly on Android, but their type systems and language features do not line up perfectly. Strong answers focus on the boundary:

  • Platform types such as String! come from unannotated Java. Kotlin cannot prove whether they are nullable, so validate them or improve the Java nullability annotations.
  • Kotlin default arguments are not Java overloads. @JvmOverloads generates overloads by removing trailing default parameters.
  • @JvmStatic exposes a companion/object function as a Java-style static method; @JvmField exposes a property as a field instead of getter/setter methods.
  • Java SAM interfaces work naturally with Kotlin lambdas. Kotlin function types exposed to Java become FunctionN types, which may be awkward for a Java caller.
  • Kotlin has no checked exceptions. Add @Throws(IOException::class) when Java callers should see a throws declaration.
class ImageLoader @JvmOverloads constructor(
    val cacheSize: Int = 100,
    val debug: Boolean = false,
) {
    companion object {
        @JvmField val DEFAULT_TAG = "Images"
        @JvmStatic fun create() = ImageLoader()
    }
}

Do not scatter JVM annotations everywhere. Add them when a Java caller, framework, reflection API, or generated code genuinely requires that JVM shape.

Code reasoning

What value does a lambda capture from a for or while loop?
Mid #kotlin#output-based#closures#lambdas
val actions = mutableListOf<() -> Int>()
for (i in 1..3) {
    actions.add { i }
}
println(actions.map { it() })

Output:

[1, 2, 3]

Why this surprises people: in Java, a similar loop with a mutable index would capture the same variable, and all lambdas would print the final value. Kotlin is different - in a for loop, each iteration has its own i. The lambda closes over that iteration’s value, so you get [1, 2, 3].

The contrast - capture a single mutable variable and they do share it:

var j = 0
val fns = mutableListOf<() -> Int>()
while (j < 3) { fns.add { j }; j++ }
println(fns.map { it() })   // [3, 3, 3] - all see the final j

Kotlin closures capture the variable, not a snapshot of its value. The loop case works out because for introduces a fresh val each iteration; the while case shares one mutable var, so every lambda sees its final value.

Optional deep dives

Internals and broader design questions to study after the core material.

Type system and generics

How do generic type constraints work in Kotlin?
Senior #kotlin#generics#bounds

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A type bound restricts what a type parameter can be. The default upper bound is Any? (anything, including null).

// Single upper bound: T must be Comparable<T>
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b

// Non-null bound - T can't be nullable
fun <T : Any> requireValue(x: T?): T = x ?: error("null")

For multiple bounds, use a where clause:

fun <T> copyWhenReady(source: T, dest: T)
    where T : CharSequence,
          T : Appendable {
    // T is guaranteed to be both CharSequence and Appendable
}

Points interviewers check:

  • An unbounded <T> defaults to T : Any?, so T may be nullable - bound it with : Any if you need non-null.
  • Bounds are how you call methods on a generic type: max above can use > only because T : Comparable<T>.
  • Combine with variance: class Box<out T : Number> is a covariant box constrained to numbers.
  • Don’t confuse a bound (T : Number, constrains the type) with variance (out T, constrains assignability).

Practical use: generic repositories/adapters (<T : Entity>), or a Compose AnimateAsState-style helper bounded to types it can interpolate.

Advanced language features

What does return do inside an inline lambda?
Senior #kotlin#output-based#lambdas#inline

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

fun foo(): String {
    listOf(1, 2, 3).forEach {
        if (it == 2) return "early"
    }
    return "done"
}

fun bar(): String {
    listOf(1, 2, 3).forEach label@{
        if (it == 2) return@label
    }
    return "done"
}

println(foo())   // ?
println(bar())   // ?

Output:

early
done

Why:

  • forEach is an inline function, so a bare return inside its lambda is a non-local return - it returns from the enclosing function foo. When it == 2, foo returns "early" immediately.
  • In bar, return@label (a labeled return) only returns from the lambda - like continue. The loop keeps going, and bar falls through to return "done".

a plain return in an inline lambda exits the surrounding function (surprising if you expected loop-continue behavior). Use return@forEach / a label to return from the lambda only. Non-local returns are only possible because forEach is inline - try it with a non-inline higher-order function and the bare return won’t compile.

What is a lambda with receiver, and how does it enable Kotlin DSLs?
Senior #kotlin#dsl#lambdas#receiver

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A lambda with receiver has type T.() -> R instead of (T) -> R. Inside the lambda, this is the receiver T, so you can call its members directly without a qualifier. This is the foundation of Kotlin DSLs.

class HtmlBuilder {
    val sb = StringBuilder()
    fun p(text: String) { sb.append("<p>$text</p>") }
}

// The block is a lambda with HtmlBuilder as receiver
fun html(block: HtmlBuilder.() -> Unit): String =
    HtmlBuilder().apply(block).sb.toString()

val page = html {
    p("Hello")     // `this` is HtmlBuilder - call p() directly
    p("World")
}

This is exactly how buildString { append(...) }, Gradle Kotlin DSL, Compose Modifier chains, and apply { } work - apply is literally fun T.apply(block: T.() -> Unit): T.

Advanced point: @DslMarker annotations stop you from accidentally calling an outer receiver’s methods inside a nested block, which keeps nested DSLs (like a table inside a row) unambiguous.

Code reasoning

In what order are Kotlin properties and init blocks initialized?
Senior #kotlin#output-based#initialization

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

class Sample {
    val a = "a".also { println("prop a") }
    init { println("init 1") }
    val b = "b".also { println("prop b") }
    init { println("init 2") }
}

fun main() { Sample() }

Output:

prop a
init 1
prop b
init 2

Why: property initializers and init blocks run in the order they’re written, top to bottom, interleaved - not “all properties, then all inits.” The constructor effectively executes them as a single sequence.

The classic trap is referencing a property declared below:

class Broken {
    init { println(x.length) }  // x not initialized yet → NullPointerException
    val x = "hi"
}

Even though x is a non-null val, at the time the init block runs it still holds its default (null), so this throws. The compiler warns you (“accessing non-initialized property”).

Lesson: declaration order is execution order. Don’t reference a property before its initializer has run.