Kotlin Language
Null safety, data classes, sealed types, generics, scope functions, delegation, and the language internals interviewers lean on.
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 whylateinitexists. - Type system -
val/var,data/sealed/enumclasses,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?
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 youlist.add(2)).const val- a compile-time constant. It’s inlined at the call site and must be a top-level orobject/companion objectmember with a primitive orStringvalue.
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?
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
?.- returnsnullinstead of throwing if the receiver is null:user?.name. - Elvis
?:- supply a fallback:user?.name ?: "Guest". - Smart casts - after a
!= nullcheck, 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.
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?
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
sealedtype orenum, the compiler requires all cases (noelseneeded), and errors if you miss one later. - Smart casts inside
isbranches. - 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?
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. internalnames are mangled in the bytecode, which is why Java callers shouldn’t rely on them.- There’s no default “open” - classes/members are
finalunless markedopen.
Classes and modeling
What does a data class generate for you, and what are its limitations?
For the properties declared in the primary constructor, the compiler generates:
equals()/hashCode()- structural equality based on those propertiestoString()- 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, orinner. - 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?
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. Onlyopenmembers 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?
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 seesUser.Companion.create(...). const valand@JvmFielddo 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?
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
@JvmOverloadsto generate overloads for them - essential when writing a customViewwhose 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?
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 }. itis the implicit name for a single-parameter lambda.- Function references - pass an existing function with
:::list.filter(::isValid). - A lambda is compiled to a
Functionobject (allocation) unless the function isinline.
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?
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).
| Function | Receiver | Returns | Typical use |
|---|---|---|---|
let | it | lambda result | null-checks, transform a value |
run | this | lambda result | run a block + return a result |
with | this | lambda result | group calls on one object (not an extension) |
apply | this | the object | configure/build an object |
also | it | the object | side 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?
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:
applyreturns the receiver object (theStringBuilder). Soais the builder →"xy".letreturns the lambda result. Forb, the last expression isit.append("y"), andStringBuilder.appendreturns the sameStringBuilder- sobis also the builder →"xy".- For
c, the lambda’s last expression is the string"done", soletreturns"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?
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/protectedmembers 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?
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
varargparameter. 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?
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?
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:
tryis 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).runCatchingwraps a block in aResult<T>, turning exceptions into values for functional handling:val result = runCatching { api.fetch() } .map { it.body } .getOrElse { fallback }Nothingis the type ofthrow, 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?
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?
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:
==callsequals()→ structural equality. All four comparisons by value aretrue.===is referential equality (same object).- The
Int?types are boxed (Integer). The JVM caches boxed integers in the range −128..127, soaandbpoint to the same cached object →a === bistrue.128is outside the cache, socanddare different boxed objects →c === disfalse.
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?
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:
varproperties (especially of another class / open): they could be modified by another thread or an overridden getter between check and use.- Custom getters: a
valwith a custom getter could return a different value each call. - Properties from another module / mutable
varglobals.
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?
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 data → sealed; open extension → abstract.
lateinit vs lazy - what's the difference and when do you use each?
Both defer initialization, but they’re for different situations.
lateinit var
- A
varyou promise to set before first use. No initial value. - Only for non-null, non-primitive types (
var x: Intwon’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,
onCreateviews/binding, test setup.
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
binding = ActivityMainBinding.inflate(layoutInflater)
}
by lazy
- A
valcomputed 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?
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)
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
fieldavoids infinite recursion. Writingset(value) { counter = value }would call the setter again forever -field = valuewrites storage directly. - A property with only a custom getter and no
fieldreference stores nothing - it’s computed each call. - A common pattern is the private mutable / public read-only pair (no custom
fieldneeded):
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state // expose read-only view How does destructuring work, and what is the componentN convention?
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) = usersilently assignsname = user.idif 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?
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?
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, andMeterscan’t have its arguments swapped, unlike threeStrings. - 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
initbacking 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?
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 = String | value class UserId(val v: String) | |
|---|---|---|
| New distinct type? | No | Yes |
| Type-safe (prevents mixups)? | No | Yes |
| Runtime cost | None | None (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?
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 whyview.setOnClickListener { }works. - For Kotlin interfaces it only kicks in with the
fun interfacekeyword; otherwise the compiler prefers you use a function type ((Int) -> Boolean) directly. - A
fun interfacecan 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?
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?
You overload an operator by defining a function with a reserved name and the operator modifier. Kotlin maps symbols to these functions:
| Operator | Function |
|---|---|
a + b | a.plus(b) |
a[i] | a.get(i) |
a[i] = v | a.set(i, v) |
a in b | b.contains(a) |
a..b | a.rangeTo(b) |
a == b | a.equals(b) |
+a / -a | unaryPlus / 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 throughequals(with a null check), and===(referential) can’t be overloaded.- Overloading
invokeis howMutableState-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?
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
returninside the lambda can return from the enclosing function. reifiedtype 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 aRunnable/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?
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?
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:
foldvsreduce-reducestarts from the first element and throws on an empty list;foldtakes an explicit initial accumulator (and can change the result type).associatevsassociateByvsgroupBy-associatebuilds key→value pairs you specify;associateBykeys by a selector (one value per key, last wins);groupBykeys to a list of all matching values.mapvsflatMap-flatMapis 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.
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.
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?
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.
@JvmOverloadsgenerates overloads by removing trailing default parameters. @JvmStaticexposes a companion/object function as a Java-style static method;@JvmFieldexposes 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
FunctionNtypes, which may be awkward for a Java caller. - Kotlin has no checked exceptions. Add
@Throws(IOException::class)when Java callers should see athrowsdeclaration.
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?
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?
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 toT : Any?, soTmay be nullable - bound it with: Anyif you need non-null. - Bounds are how you call methods on a generic type:
maxabove can use>only becauseT : 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?
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:
forEachis an inline function, so a barereturninside its lambda is a non-local return - it returns from the enclosing functionfoo. Whenit == 2,fooreturns"early"immediately.- In
bar,return@label(a labeled return) only returns from the lambda - likecontinue. The loop keeps going, andbarfalls through toreturn "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?
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?
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.