Kotlin Interview Questions

Kotlin language questions asked in Android interviews: null safety, scope functions, data and sealed classes, generics and variance, collections, exceptions, Java interop and newer features from Kotlin 2.0 onwards. Expect to explain why a feature exists, not just its syntax.

By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked

50 questions with answers1 easy · 31 medium · 18 hard36 marked most asked

All 50 questions

  1. Explain Kotlin's null safety system. What is the difference between ?, !!, ?:, and let? Most asked
  2. What is the difference between val, var, const val, lateinit var, and by lazy? Most asked
  3. What is a data class in Kotlin? What does it auto-generate and what are its limitations? Most asked
  4. What is the difference between sealed class, sealed interface, and enum class? When do you use each? Most asked
  5. Explain scope functions: let, run, with, also, apply. How do you choose between them? Most asked
  6. What are higher-order functions and lambdas in Kotlin? How do they work under the hood? Most asked
  7. What are extension functions and extension properties? How are they resolved? Most asked
  8. What are inline functions? Explain noinline and crossinline modifiers. Most asked
  9. Explain Generics in Kotlin. What is variance — in, out, and star projection? Most asked
  10. What is the reified keyword and why can it only be used with inline functions? Most asked
  11. Explain delegation in Kotlin — class delegation and property delegation. What are built-in delegates? Most asked
  12. What is operator overloading in Kotlin? What operators can be overloaded? Most asked
  13. What are Kotlin collections? Explain the difference between List, MutableList, Sequence, and when to use each. Most asked
  14. What is the when expression in Kotlin? How is it different from Java's switch? Most asked
  15. What is destructuring in Kotlin? How do componentN functions work? Most asked
  16. What are object, companion object, and anonymous objects in Kotlin? Most asked
  17. How does Kotlin handle exceptions? What is the difference from Java? Most asked
  18. What are Kotlin Contracts? How do they help the compiler understand your code?
  19. What is typealias in Kotlin and when is it useful? Most asked
  20. What are value classes (inline classes) in Kotlin? When should you use them?
  21. What are visibility modifiers in Kotlin? How is internal different from Java's package-private? Most asked
  22. How do you build a DSL in Kotlin? What language features make this possible?
  23. What is Kotlin's type system? Explain Any, Unit, Nothing, and how they differ from Java's Object, void, Void. Most asked
  24. What are the key advantages of Kotlin over Java for Android development? Most asked
  25. What is Kotlin Serialization? How does it compare to Gson and Moshi?
  26. What is the difference between == and === in Kotlin? Most asked
  27. What are Kotlin's string templates and multiline strings? Most asked
  28. What is the difference between List and Array in Kotlin? Most asked
  29. How does Kotlin interoperate with Java? What are @JvmStatic, @JvmField, @JvmOverloads? Most asked
  30. What are named and default arguments in Kotlin? How do they reduce boilerplate? Most asked
  31. What is Kotlin's type inference? How does it work with local variables and generics?
  32. What are Kotlin's collection transformation functions? Explain map, filter, reduce, fold, groupBy, partition. Most asked
  33. What is Kotlin's Result type and how does it differ from exceptions?
  34. What are Kotlin annotations? How do you create custom annotations? Most asked
  35. What is Kotlin Reflection? When would you use it in Android?
  36. What are infix functions in Kotlin? Give real-world examples. Most asked
  37. What are varargs in Kotlin? How does the spread operator work? Most asked
  38. What is Kotlin's Nothing type? How is it used in practice?
  39. What is lazy initialization vs eager initialization in Kotlin? When to use each? Most asked
  40. What are Kotlin's open and abstract classes? How does Kotlin differ from Java in class design?
  41. What is the difference between map and flatMap in Kotlin collections? Most asked
  42. How does Kotlin handle SAM (Single Abstract Method) conversions? Most asked
  43. What are Kotlin coroutine basics — suspend functions and how does the compiler transform them?
  44. What are Kotlin's range and progression operators? Most asked
  45. What is Kotlin's context receivers feature? How does it improve code organization?
  46. What is the difference between Kotlin's List, Set, and Map? When do you use each? Most asked
  47. What is Kotlin's object expressions vs object declarations vs companion objects — when to use each?
  48. How do you write idiomatic Kotlin? What are the key code style principles? Most asked
  49. What are Kotlin's new features in 2024-2025 — Kotlin 2.0 highlights?
  50. How does Kotlin Multiplatform work? What can and cannot be shared across platforms?
EasyMost asked

1. Explain Kotlin's null safety system. What is the difference between ?, !!, ?:, and let?

Null safety is arguably Kotlin's single biggest improvement over Java. Tony Hoare — who invented null references — called it his "billion dollar mistake." In Android specifically, NullPointerException is the #1 cause of app crashes in production. Kotlin solves this at the type system level: every type is either nullable (String?) or non-nullable (String), and the compiler enforces that you handle the null case before using a nullable value. This means most NPEs are compile-time errors in Kotlin, not runtime crashes. The four operators — ?, ?., ?:, and !! — are the vocabulary for working safely with nullable values, and understanding them deeply is fundamental to writing idiomatic Kotlin.

// Non-nullable — compiler guarantees never null
var name: String = "Rahul"
name = null  // ❌ Compile error

// Nullable — can hold null
var name: String? = "Rahul"
name = null  // ✅ fine

// ?. Safe call — only calls if not null
val length = name?.length  // returns null if name is null
name?.uppercase()?.trim()   // chain safe calls

// ?: Elvis operator — provide default if null
val length = name?.length ?: 0  // 0 if name is null
val user = findUser() ?: throw IllegalStateException("User not found")

// !! Not-null assertion — throws NPE if null (use sparingly!)
val length = name!!.length  // throws KotlinNullPointerException if null

// let — execute block only if not null
name?.let { nonNullName ->
    println("Name is: $nonNullName") // nonNullName is String, not String?
}

// Nullable return type check
fun getUser(): User? = db.findUser()

// Smart cast after null check
if (name != null) {
    println(name.length) // name is smart-cast to String
}

? (nullable type) declares that a variable can hold null. String means never null; String? means it can be null. The compiler refuses to let you call methods on a String? without handling the null case — it's a compile error, not a runtime crash.

?. (safe call) calls a method or accesses a property only if the receiver is non-null. If it is null, the entire expression short-circuits and returns null instead of throwing an NPE. You can chain them — user?.address?.city returns null if any step in the chain is null, with no crashes at any point.

?: (Elvis operator) provides a fallback when the left side is null. val name = user?.name ?: "Guest" uses "Guest" if either user or name is null. The right side doesn't have to be a value — you can also write ?: return or ?: throw IllegalStateException(...) for early exits.

!! forces a nullable type to non-nullable and throws KotlinNullPointerException if the value is actually null. Treat it as a code smell — using it frequently means your types aren't modelling the domain correctly. It's only justified when calling unannotated Java APIs where you're certain the value won't be null but the type system can't prove it.

let for null checks — value?.let { } runs the block only when the value is non-null, with it smart-cast to the non-nullable type inside. It's the idiomatic alternative to a verbose if (value != null) { } block, especially when chained after a safe call.

Smart casts — after a null check or an is check, Kotlin automatically narrows the type within that branch. No explicit cast needed: inside if (name != null) { }, name is treated as String, not String?. Likewise, if (shape is Circle) { shape.radius } auto-casts shape to Circle.

Platform types (Java interop) — when calling Java code, Kotlin can't know if a return value is nullable or not. These "platform types" (shown as String! in the IDE) bypass null safety entirely. Always annotate Java code with @Nullable/@NonNull, or add defensive null checks on the Kotlin side when consuming Java APIs.

lateinit var declares a non-nullable property that will be set before first use — typically by a framework (Activity.onCreate, Hilt injection, test setUp). Accessing it before initialisation throws UninitializedPropertyAccessException. Check it safely with ::property.isInitialized. Doesn't work with primitives — use nullable types or Delegates.notNull() instead.

💡 Interview Tip

The most impressive answer explains WHY Kotlin has null safety (NPE is Android's #1 crash cause), then walks through operators in order of safety: ?. is always safe, ?: is always safe, !! is a smell. The killer follow-up: 'When would you use !!' — answer: almost never, only when calling unannotated Java APIs where you're certain of non-null. Also mention platform types from Java interop — most candidates don't know about this gap in null safety.

Mark it done or save it in the question bank · Back to top

MediumMost asked

2. What is the difference between val, var, const val, lateinit var, and by lazy?

Kotlin provides five distinct ways to declare and initialize values, each suited for a different scenario. Getting these right is fundamental — using var when you should use val leads to bugs, using lateinit when you should use lazy leads to thread-safety issues, and confusing const val with regular val leads to performance and R8 optimization losses. A senior Kotlin developer instinctively picks the right one without thinking: val for most things, const val for compile-time constants, lateinit var for framework-injected properties, by lazy for expensive one-time computations, and var only when mutation is genuinely required.

// val — immutable reference, set once (like Java final)
val name = "Rahul"  // cannot reassign, object content can still change
val list = mutableListOf(1, 2)
list.add(3)  // ✅ list reference is val, content is mutable

// var — mutable reference, can be reassigned
var count = 0
count = 5  // ✅

// const val — compile-time constant
// Must be: top-level or in object/companion object, primitive or String, no custom getter
const val BASE_URL = "https://api.myapp.com"  // inlined at compile time
const val MAX_RETRY = 3
// val BASE_URL = "..."  → evaluated at runtime, slower for annotations

// lateinit var — defer initialization, non-null type
// Use for: Dependency Injection, @Before setup in tests, View Binding
lateinit var binding: ActivityMainBinding
lateinit var viewModel: UserViewModel

// Check before access to avoid UninitializedPropertyAccessException
if (::binding.isInitialized) { binding.doSomething() }

// by lazy — initialize on first access, thread-safe by default
// Can only be used with val
val heavyObject by lazy {
    println("Initialized!")  // only runs once, on first access
    HeavyObject()
}

// lazy modes
val obj by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { /* default, thread-safe */ }
val obj by lazy(LazyThreadSafetyMode.NONE) { /* single-thread, no lock overhead */ }

val is an immutable reference — once assigned it can't be reassigned, like Java's final. This does not mean the object itself is immutable: a val list pointing to a MutableList can still have items added. Prefer val everywhere possible — it communicates intent and makes concurrent code safer without explicit synchronisation.

var is a mutable reference that can be freely reassigned. Use it only when reassignment is genuinely required — in ViewModels, reach for StateFlow instead so changes are observable. Every var in your code is a potential source of bugs; ask yourself whether it can be a val first.

const val is a compile-time constant. It must be a primitive or String, declared at the top level or inside an object or companion object, with no custom getter. The compiler inlines the literal value at every usage site — there's no field access at runtime. This matters for annotation arguments (which require compile-time constants) and for R8/ProGuard, which can remove const val fields entirely.

lateinit var is a non-nullable property that promises it will be initialised before first access. Common cases: ViewBinding set in onCreate, Hilt @Inject fields, properties set up in a test's @Before method. Accessing it before initialisation throws UninitializedPropertyAccessException. Check with ::field.isInitialized. Does not work with primitives — use nullable types or Delegates.notNull() instead.

by lazy runs its initialiser block exactly once — on first access — and caches the result for all future reads. Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED). Use it for expensive objects that may not always be needed. Three modes: SYNCHRONIZED (default), PUBLICATION (multiple threads may initialise, first wins), and NONE (no locking, single-threaded use only).

A quick decision rule: if you can initialise immediately, use val. If the framework sets the value for you (Activity lifecycle, DI), use lateinit var. If initialisation is expensive or can be deferred, use by lazy. Only reach for var when genuine reassignment is needed.

💡 Interview Tip

Two things set senior answers apart: (1) const val is inlined by the compiler — 'it's not just immutable, the compiler replaces every reference with its literal value, so there's no field access at runtime.' (2) The lateinit isInitialized check — many developers don't know you can safely check ::field.isInitialized before accessing a lateinit. Also: lateinit doesn't work with nullable types or primitives — a very common gotcha.

Mark it done or save it in the question bank · Back to top

MediumMost asked

3. What is a data class in Kotlin? What does it auto-generate and what are its limitations?

A data class is Kotlin's mechanism for modeling pure data — classes whose primary purpose is to hold values rather than define behavior. Think of it as Kotlin's answer to Java's notorious record verbosity: instead of writing 100 lines for a simple model class (constructor, getters, equals, hashCode, toString, copy), you write one line. The compiler automatically generates five types of functions based on all properties declared in the primary constructor. But data classes have important limitations that trip up developers — especially around inheritance, mutable properties, and the shallow copy behavior — making it essential to know not just what they generate, but when NOT to use them.

data class User(
    val id: String,
    val name: String,
    val age: Int = 0  // default value
)

// Auto-generated functions:
val u1 = User("1", "Rahul", 25)
val u2 = User("1", "Rahul", 25)

// equals() — compares all primary constructor properties
u1 == u2  // true (structural equality)
u1 === u2 // false (referential equality — different objects)

// hashCode() — consistent with equals()
println(u1.hashCode() == u2.hashCode())  // true

// toString() — readable output
println(u1)  // User(id=1, name=Rahul, age=25)

// copy() — shallow copy with optional field overrides
val u3 = u1.copy(name = "Priya")  // User(id=1, name=Priya, age=25)

// componentN() — destructuring support
val (id, name, age) = u1
println("$id $name $age")  // 1 Rahul 25

// Limitations of data class:
// ❌ Cannot be abstract, open, sealed, or inner
// ❌ Only primary constructor properties included in equals/hashCode/toString
// ❌ copy() is SHALLOW — nested objects share references
data class Team(val members: MutableList<User>)
val team1 = Team(mutableListOf(u1))
val team2 = team1.copy()
team2.members.add(u2)
println(team1.members.size)  // 2! — shallow copy, same list reference

The five auto-generated functions — equals(), hashCode(), toString(), copy(), and componentN() — are all derived from the properties declared in the primary constructor only. Body-level properties are ignored by the compiler entirely. This is a common source of bugs: a developer adds a secondary property inside the class body expecting it to affect equality, and it silently doesn't.

equals() is structural, not referential. Two data class instances with identical primary constructor values are considered equal (== returns true), even if they're different objects in memory. For reference equality, use ===. This makes data classes ideal for UI state diffing — RecyclerView's DiffUtil and Compose's recomposition both rely on structural equality.

copy() is shallow. val updated = user.copy(name = "Bob") creates a new instance with most properties copied. But any nested mutable object — a MutableList, another class — is shared between the original and the copy. Modifying the list in one affects the other. For deep copies you have to do it manually, or round-trip through serialization.

Prefer immutable data classes. When properties are var, the object can change after creation. If you put a mutable data class in a Set or use it as a Map key, the hashCode changes with the properties — and the item gets lost. Use val properties and simulate mutation with copy(): state = state.copy(isLoading = true).

Limitations. Data classes cannot be abstract, open, sealed, or inner, and cannot extend other classes (though they can implement interfaces). They're best for simple, leaf-level model objects: DTOs, domain models, UI state containers. For class hierarchies, combine them with sealed classes — sealed class UiState { data class Success(val data: List<Item>) : UiState() }.

💡 Interview Tip

The shallow copy trap is a classic follow-up: 'If you do team1.copy(), what happens to the list inside it?' — the copy shares the same list reference as the original. To deep copy, you must do team1.copy(members = team1.members.toMutableList()). Also mention that data classes work great with sealed classes for UI state modeling — combine the two concepts to show architectural breadth.

Mark it done or save it in the question bank · Back to top

MediumMost asked

4. What is the difference between sealed class, sealed interface, and enum class? When do you use each?

Sealed classes, sealed interfaces, and enum classes all restrict what values or subtypes are possible — but they solve different problems. Enums are for a fixed set of identical-shaped constants (like compass directions or HTTP methods). Sealed classes are for a fixed set of related types that can carry different data and different state per instance — the classic example is a UI state machine with Loading, Success(data), and Error(message) states. Sealed interfaces were introduced in Kotlin 1.5 and allow a single class to be part of multiple sealed hierarchies — something sealed classes don't support. The killer feature of all three: when you use them in a when expression, the compiler enforces exhaustiveness, making it impossible to forget handling a new state.

// Enum class — fixed set of constants, same type, no state variation
enum class Direction { NORTH, SOUTH, EAST, WEST }
enum class Status(val code: Int) {
    SUCCESS(200), NOT_FOUND(404), ERROR(500)
}
// All instances are singletons — cannot have different state per instance

// Sealed class — restricted class hierarchy, each subclass can have different state
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String, val code: Int) : UiState<Nothing>()
}

// Sealed interface — like sealed class but allows multiple inheritance
sealed interface NetworkResult
sealed interface CacheResult

data class Success(val data: String) : NetworkResult, CacheResult  // implements both!
data class NetworkError(val code: Int) : NetworkResult
data class CacheError(val reason: String) : CacheResult

// Exhaustive when — compiler enforces all cases
when (getState()) {
    is UiState.Loading  -> showLoader()
    is UiState.Success  -> showData(it.data)
    is UiState.Error    -> showError(it.message)
    // No else needed — compiler knows all cases covered
}

enum class is for a fixed set of named constants that are all the same shape — each instance is a singleton. You can attach properties and functions to enum entries, but every entry shares the same type and structure. Use it when values are conceptually interchangeable and don't carry different data: Direction (NORTH, SOUTH, EAST, WEST), HttpMethod (GET, POST, PUT), Priority (LOW, MEDIUM, HIGH).

sealed class is for a closed type hierarchy where each subclass can have a completely different structure. Unlike enums, each subtype can carry different fields, different state, and different constructors. All subtypes must be declared in the same package. The canonical Android use case is UI state: Loading (no data), Success(val data: List<Item>), Error(val message: String) — three different shapes, impossible to model with an enum.

sealed interface (Kotlin 1.5+) works like a sealed class but removes the single-inheritance restriction. A class can implement multiple sealed interfaces, making it useful when a type needs to participate in more than one hierarchy. For example, a Success result can implement both NetworkResult and CacheResult, which a sealed class wouldn't allow.

Exhaustive when is the real superpower. When you use a sealed class or enum in a when expression without an else branch, the compiler verifies every subtype is handled. Add a new subtype later, and every when that doesn't handle it immediately fails to compile. This turns a runtime bug — "forgot to handle the new state" — into a compile-time error caught before anything ships.

Sealed vs abstract class — an abstract class can be extended anywhere in the codebase; the compiler has no idea how many subclasses exist. A sealed class is closed: all subclasses are known at compile time, which is why exhaustive when is possible. Think of it as an abstract class with a "closed world" assumption.

💡 Interview Tip

Use the decision rule directly: 'enum for a fixed set of constants with no per-instance data (Direction, Day); sealed class for state machines where each subtype carries different data (Loading, Success(data), Error(message)).' The sealed interface angle is impressive — mention it when discussing multimodule projects where different modules need to add cases to a hierarchy without modifying a shared base.

Mark it done or save it in the question bank · Back to top

MediumMost asked

5. Explain scope functions: let, run, with, also, apply. How do you choose between them?

Scope functions are one of Kotlin's most powerful but most confusing features for beginners. All five — let, run, with, also, apply — execute a block of code in the context of an object, but they differ along exactly two axes: how the context object is referenced (as this or as it), and what the function returns (the context object itself, or the lambda's result). Choosing the wrong one doesn't break compilation but makes code harder to read. A simple mental model: if you're configuring an object → apply. If you're doing a side effect on an object → also. If you want to transform an object → let or run. With is for grouping multiple calls on an already-existing object.

// Decision matrix:
// Function | Reference | Returns        | Use for
// let      | it        | lambda result  | null check, transform
// run      | this      | lambda result  | compute result, null check
// with     | this      | lambda result  | group calls (non-nullable only)
// also     | it        | object itself  | side effects (logging, debugging)
// apply    | this      | object itself  | object configuration/initialization

// let — transform or null-check
val upperName = user?.name?.let { it.uppercase() }  // only if not null
val result = numbers.let { list -> list.filter { it > 0 }.sum() }

// apply — configure an object, returns the object
val intent = Intent(this, MainActivity::class.java).apply {
    putExtra("userId", "123")  // this = Intent
    flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}

// also — side effects without changing object
val user = User("Rahul")
    .also { log("Created user: ${it.name}") }  // it = User, returns User
    .also { analytics.track("user_created") }

// run — compute something with context
val isValid = user?.run {
    name.isNotEmpty() && age > 0  // this = User
} ?: false

// with — group multiple calls on an object
val result = with(textView) {
    text = "Hello"       // this = TextView
    textSize = 16f
    setTextColor(Color.RED)
    visibility = View.VISIBLE
}

apply { } — the context object is this and the function returns the object itself. Use it when you're configuring an object immediately after creating it: Intent(...).apply { putExtra("id", 123); flags = FLAG_ACTIVITY_SINGLE_TOP }. Because it returns the object, apply chains naturally at the end of a constructor call.

also { } — the context object is it and the function returns the object. Use it for side effects that don't change the object — logging, analytics, validation. fetchUser().also { log("Fetched: $it") }. It slots into a chain without interrupting the main flow because the object passes through unchanged.

let { } — the context object is it and the function returns the lambda result. Its primary use is null-safe operations: user?.let { sendEmail(it) } runs only when user is non-null, with it smart-cast to non-nullable inside. Also useful for transformations where you want a local name: text.let { it.trim().length }.

run { } — the context object is this and the function returns the lambda result. Use it when you want to compute something from an object's properties without repeating its name: user?.run { name.isNotEmpty() && age > 0 } ?: false. Also used standalone as a block expression that returns a value.

with(obj) { } — the context object is this and the function returns the lambda result. Unlike the others, it's a regular function call, not an extension. Use it to group multiple calls on an existing non-nullable object: with(binding) { tvTitle.text = title; tvBody.text = body }. Doesn't chain as naturally as the extension versions.

A quick decision shortcut: if you need to return the same object you started with, use apply (context as this) or also (context as it). If you need the result of the block, use run or with (context as this) or let (context as it). Null checking almost always calls for let; object setup almost always calls for apply.

💡 Interview Tip

The 2x2 matrix is the clearest way to explain scope functions: rows = 'returns the object (apply/also) vs returns the result (let/run/with)', columns = 'receiver as this (apply/run/with) vs receiver as it (let/also).' The most common interview follow-up is 'when do you use let vs run?' — both use the receiver as a result, but let uses 'it' (good for nullable chains) and run uses 'this' (good for object configuration).

Mark it done or save it in the question bank · Back to top

MediumMost asked

6. What are higher-order functions and lambdas in Kotlin? How do they work under the hood?

Higher-order functions and lambdas are the foundation of Kotlin's functional programming capabilities — and understanding how they work under the hood is critical for writing performant Android code. A higher-order function is simply one that takes a function as a parameter or returns a function. Lambdas are the anonymous function literals you pass to them. The hidden cost: every lambda creates an anonymous class instance (a Function0, Function1, etc. object) that is heap-allocated, adding garbage collection pressure. This is why Kotlin's inline functions exist — they eliminate the object allocation by copying the lambda's bytecode directly to the call site at compile time.

// Higher-order function — takes a function as parameter
fun performOperation(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
    return operation(x, y)
}

// Lambda expressions
val sum = performOperation(3, 4) { a, b -> a + b }  // 7
val multiply = performOperation(3, 4) { a, b -> a * b }  // 12

// Function type syntax
val greet: (String) -> String = { name -> "Hello, $name!" }
val double: (Int) -> Int = { it * 2 }  // 'it' for single param

// Return a function (function factory)
fun multiplier(factor: Int): (Int) -> Int = { number -> number * factor }
val triple = multiplier(3)
println(triple(5))  // 15 — closure captures 'factor'

// Function references
fun isEven(n: Int) = n % 2 == 0
val evens = listOf(1, 2, 3, 4).filter(::isEven)  // [2, 4]
val lengths = listOf("a", "bb").map(String::length)   // [1, 2]

// Under the hood: lambda creates anonymous class implementing Function1<Int,Int>
// This means: each lambda = object allocation = GC pressure
// Solution: inline functions (see Q8)

// Common HOF in Kotlin stdlib
val doubled = listOf(1,2,3).map { it * 2 }     // [2, 4, 6]
val evens2 = listOf(1,2,3).filter { it % 2 == 0 } // [2]
val sum2 = listOf(1,2,3).reduce { acc, n -> acc + n }  // 6

Higher-order functions take other functions as parameters or return functions. This unlocks patterns that are verbose or impossible in Java without interfaces: fun <T> retry(times: Int, block: () -> T): T retries any operation up to N times in a single reusable function. Kotlin's standard library — map, filter, forEach, let, apply — is built entirely from higher-order functions.

Lambda syntax — { param -> expression }. With a single parameter, you can drop the declaration and use it implicitly. When a lambda is the last argument to a function, it can be moved outside the parentheses — trailing lambda syntax — which is what makes lifecycleScope.launch { } and buildString { } feel natural rather than like function calls.

Under the hood — object allocation. Every lambda compiles to an anonymous class implementing FunctionN (Function0 for no args, Function1 for one, and so on). Each call to a higher-order function can create a new object on the heap, contributing to garbage collection pressure. In tight loops or frequently-called code paths this adds up. The fix is the inline keyword.

Closures. Lambdas can capture variables from their enclosing scope — including mutable ones, unlike Java where captured variables must be effectively final. Captured mutable variables are wrapped in a Ref object on the heap. This is powerful, but it means the lambda holds a reference to everything it captures — a common source of memory leaks when a lambda outlives its enclosing context.

Function types — (Int, String) -> Boolean is a first-class type in Kotlin. You can store functions in variables, pass them as arguments, return them from functions, and collect them in lists. Nullable function types look like ((Int) -> Unit)?. Invoke them with .invoke() or just ().

inline is the fix for allocation overhead. Marking a higher-order function inline tells the compiler to copy both the function body and every lambda argument's body to the call site — no anonymous class, no object allocation. This is why forEach, map, filter, let, and run in the standard library are all inline — they have zero overhead compared to writing the equivalent loop by hand. As a bonus, inline functions also enable non-local returns and reified type parameters.

💡 Interview Tip

Mentioning that lambdas create anonymous Function objects — and that inline eliminates this overhead — immediately signals you understand the JVM cost model. The most impressive follow-up: 'In RecyclerView adapters or onClickListener patterns that run thousands of times, lambda object creation adds GC pressure. inline removes it entirely.' Also: non-local returns are only possible from inline lambdas — this is why forEach is inline.

Mark it done or save it in the question bank · Back to top

MediumMost asked

7. What are extension functions and extension properties? How are they resolved?

Extension functions are one of the most beloved Kotlin features — they let you add new functions to any class, including third-party libraries and Android framework classes you can't modify, without subclassing or the Decorator pattern. Syntactically they look like member functions; at runtime they're compiled to static methods that take the extended class as the first parameter. This is crucial to understand: extension functions don't actually modify the class, they don't have access to private members, and they're resolved statically based on the declared type — NOT the runtime type. This last point is a common source of subtle bugs.

// Extension function
fun String.toTitleCase(): String =
    split(" ").joinToString(" ") { it.lowercase().replaceFirstChar { c -> c.uppercase() } }

println("hello world".toTitleCase())  // "Hello World"

// Extension property
val String.wordCount: Int
    get() = trim().split(Regex("""\s+""")).size

println("Hello World".wordCount)  // 2

// Extension on nullable type
fun String?.orEmpty(): String = this ?: ""

// Compile output — static method with receiver
// Java equivalent:
// public static String toTitleCase(String $this) { ... }

// KEY: Extensions resolved STATICALLY at compile time
open class Animal
class Dog : Animal()

fun Animal.speak() = println("Animal speaks")
fun Dog.speak() = println("Dog barks")

val dog: Animal = Dog()
dog.speak()  // "Animal speaks" — resolved by DECLARED type, not runtime type!

// Member functions always win over extensions with same name
// Extensions cannot access private members
// Extensions cannot be overridden polymorphically

Compilation to static methods. An extension function on String compiles to a static method with the receiver as the first parameter: public static boolean isPalindrome(String $this). Calling "racecar".isPalindrome() is purely syntactic sugar. The class is not modified in any way — extensions can only access public members, never private or protected ones.

Static dispatch — the most important gotcha. Extensions are resolved at compile time based on the declared type, not the runtime type. If you have a variable declared as Animal but the actual object is a Dog, and both have an extension named speak(), the Animal extension runs — always. This is fundamentally different from overriding member functions, where virtual dispatch picks the subclass implementation at runtime.

Member functions always win. If a class already has a member function with the exact same signature as an extension you're trying to add, the member function silently wins every call. You cannot override existing behaviour with extensions — only add new behaviour that didn't exist before.

Extension properties work the same way as extension functions but with property syntax. They don't carry backing fields and can't store state — they're getter (and optionally setter) methods with dot notation. val String.lastChar: Char get() = this[length - 1]. Zero memory overhead, purely syntax.

Practical uses in Android. Extension functions are everywhere in idiomatic Android code: fun Context.dpToPx(dp: Int): Int, fun View.show() { visibility = View.VISIBLE }, fun Fragment.hideKeyboard(). They add behaviour to framework classes you can't subclass, replacing sprawling utility classes with readable functions that live directly on the types they extend.

Scope. Top-level extension functions are available across the whole module and can be imported like any other function. Extensions defined inside a class are scoped to that class only. This makes them a clean way to organise utility code without polluting the global namespace or creating God-object Util classes.

💡 Interview Tip

The static dispatch gotcha is the question that separates candidates who've read the docs from those who've been burned in production: 'If you declare a variable as Animal but assign a Dog, calling an extension on it uses Animal's extension, not Dog's.' Emphasize that extensions are resolved at compile time based on declared type, not runtime type. This is very different from overriding methods and trips up many developers.

Mark it done or save it in the question bank · Back to top

HardMost asked

8. What are inline functions? Explain noinline and crossinline modifiers.

Inline functions are Kotlin's compile-time optimization for higher-order functions — when you mark a function as inline, the compiler copies the function's bytecode AND the lambda parameters' bytecode directly to every call site. No anonymous class is created, no virtual dispatch overhead, no object allocation. This eliminates the performance cost of higher-order functions in hot paths. But inlining comes with rules and two special modifiers — noinline and crossinline — for the cases where you don't want the standard inline behavior. Understanding these three modifiers is an intermediate-to-senior Kotlin topic that shows real depth of knowledge.

// Without inline — lambda creates an anonymous object every call
fun measure(block: () -> Unit) {
    val start = System.nanoTime()
    block()
    println(System.nanoTime() - start)
}

// With inline — block body copied to call site, no object allocation
inline fun measure(block: () -> Unit) {
    val start = System.nanoTime()
    block()
    println(System.nanoTime() - start)
}

// Enables NON-LOCAL returns (return from outer function)
inline fun findFirst(list: List<Int>, predicate: (Int) -> Boolean): Int? {
    list.forEach { if (predicate(it)) return it }  // returns from outer function!
    return null
}

// noinline — don't inline a specific lambda parameter
// Use when: lambda is stored, passed to another function
inline fun doSomething(inlined: () -> Unit, noinline stored: () -> Unit) {
    val ref = stored  // ✅ can store noinline lambda as variable
    // val ref2 = inlined  ❌ cannot store inlined lambda
    inlined()
    stored()
}

// crossinline — allow inlining but prevent non-local returns
inline fun runLater(crossinline action: () -> Unit) {
    Handler(Looper.getMainLooper()).post {
        action()  // crossinline: inlined but no non-local return allowed
    }
}

// reified — only possible with inline functions
inline fun <reified T> startActivity(context: Context) {
    context.startActivity(Intent(context, T::class.java))
}
startActivity<HomeActivity>(context)  // no Class parameter needed!

How inline works. The compiler substitutes the entire function body and every lambda parameter's body at each call site during compilation. The result is bytecode identical to writing the implementation inline manually — no anonymous class, no virtual dispatch, no object allocation. The tradeoff is slightly larger bytecode: the function body is duplicated at every call site, so inline is best kept for small utility functions (roughly 1–10 lines).

Non-local returns are only possible inside inline lambdas. Inside a regular lambda, return exits the lambda itself. Inside an inline lambda, return exits the enclosing function — this is called a non-local return. It's why list.forEach { if (it == 0) return } exits the outer function, not just the forEach block. Without inlining, this would be a compile error.

noinline opts a specific lambda parameter out of inlining, even when the surrounding function is inline. You need it when you want to store a lambda in a variable, pass it to another non-inline function, or return it — inlined lambdas exist only at the call site and can't be referenced as objects. inline fun track(noinline onError: () -> Unit) — the onError lambda can be stored and passed around; other lambdas in the same function are still inlined normally.

crossinline marks a lambda that will be inlined but called in a different execution context — typically inside another lambda, a Runnable, or a callback. Because the lambda isn't called directly from the inline function's body, non-local returns would be semantically ambiguous, so crossinline disallows them while still allowing the inlining optimisation. inline fun runLater(crossinline action: () -> Unit) { handler.post { action() } }.

reified type parameters are only possible because of inlining. Generic types are erased at runtime on the JVM, but inline functions are expanded at the call site where the actual type is known — so the compiler can substitute the real type. This is the mechanism behind inline fun <reified T: Activity> Context.startActivity() and the standard library's filterIsInstance<T>(). Without inline, reified is impossible.

💡 Interview Tip

Don't inline large functions — it copies the entire function body to every call site, increasing bytecode size and potentially causing Android's 64K dex method limit to be hit faster. The rule of thumb: inline is best for small utility functions that accept lambdas (1–10 lines). Also: noinline and crossinline are rarely needed but interviewers ask about them — noinline prevents a lambda from being inlined, crossinline prevents non-local returns.

Mark it done or save it in the question bank · Back to top

HardMost asked

9. Explain Generics in Kotlin. What is variance — in, out, and star projection?

Generics let you write type-safe code that works with any type — avoiding casts and providing compile-time type checking. Kotlin's generics work similarly to Java's, but with key improvements: declaration-site variance (out/in modifiers on the type parameter itself) instead of Java's use-site wildcards (? extends T, ? super T). Understanding variance is one of the hardest Kotlin/JVM topics but is essential for writing correct generic APIs. The core question: if Dog is a subtype of Animal, is List<Dog> a subtype of List<Animal>? The answer depends on how the generic type is used — if it only produces values (read-only), yes; if it can also consume values (writable), no.

// Generic class — works with any type T
class Box<T>(val value: T)
val intBox = Box(42)       // Box<Int>
val strBox = Box("hello")  // Box<String>

// Invariant (default) — Box<Dog> is NOT a Box<Animal>
fun feed(box: Box<Animal>) { }
// feed(Box<Dog>())  ❌ compile error — invariant!

// out (covariant) — Producer, only returns T, never consumes it
// Box<Dog> IS-A Box<Animal>
class Producer<out T>(val value: T) {
    fun get(): T = value       // ✅ can return T
    // fun set(v: T) { }       ❌ cannot accept T as input
}
val dogs: Producer<Dog> = Producer(Dog())
val animals: Producer<Animal> = dogs  // ✅ Dog is Animal, so Producer<Dog> is Producer<Animal>
// Real example: List<out E> — you can read, not write

// in (contravariant) — Consumer, only accepts T, never produces it
// Box<Animal> IS-A Box<Dog> (reversed!)
class Consumer<in T> {
    fun process(value: T) { }  // ✅ can accept T
    // fun get(): T { }         ❌ cannot return T
}
val animalProcessor: Consumer<Animal> = Consumer()
val dogProcessor: Consumer<Dog> = animalProcessor  // ✅ Consumer<Animal> is Consumer<Dog>
// Real example: Comparator<in T>

// Star projection — unknown type, read-only
fun printSize(list: List<*>) {  // accepts List<Any>, List<String>, etc.
    println(list.size)  // can read, elements are Any?
}

Invariance (the default) means MutableList<Dog> is not a subtype of MutableList<Animal>, even though Dog is a subtype of Animal. This is correct for mutable containers: if you could assign a MutableList<Dog> to a MutableList<Animal> variable, someone could legally add a Cat to it through that variable — breaking type safety at the point a Dog is read back out.

out (covariance) marks a type parameter that only appears in output positions — return types. The class is a producer of T: it can give you T values, but never accepts T as input. This makes Producer<Dog> safely assignable to Producer<Animal>. Kotlin's read-only List<out E> uses this: you can safely read items as Animal from a List<Dog> because nobody can add a Cat to a read-only list.

in (contravariance) marks a type parameter that only appears in input positions — function parameters. The class is a consumer of T: it accepts T values but never returns them. This reverses the subtype relationship: a Consumer<Animal> is safely assignable to Consumer<Dog>, because anything that can process an Animal can certainly process a Dog. Kotlin's Comparator<in T> is the classic example.

Star projection (*) represents an unknown type argument. List<*> means "a list of some type I don't know." You can read from it — items come out as Any? — but you can't write to it, since the unknown type means the compiler can't verify what's safe to add. Use it when the actual type doesn't matter: if (obj is List<*>) is the idiomatic way to check for a list without a raw type cast.

Type bounds constrain which types can fill a generic parameter: <T : Comparable<T>> restricts T to types that implement Comparable. Multiple bounds use a where clause: <T> where T : Comparable<T>, T : Serializable. This lets you call both Comparable and Serializable methods on T within the function.

Declaration-site vs use-site variance. Kotlin's out/in on the class declaration is cleaner than Java's use-site wildcards (? extends T, ? super T). The class author declares variance once; every caller benefits automatically. For classes you didn't write, Kotlin still allows use-site variance: fun copy(from: Array<out Any>).

💡 Interview Tip

Use the producer/consumer mnemonic: 'out = producer of T (you can only GET T out), in = consumer of T (you can only PUT T in).' PECS from Java — Producer Extends, Consumer Super — maps directly. Star projection (*) is for when you want to use a generic type but don't care about its parameter — useful for reflection but loses type information. A real example: List<out Number> can accept a List<Int>.

Mark it done or save it in the question bank · Back to top

HardMost asked

10. What is the reified keyword and why can it only be used with inline functions?

Type erasure is a fundamental JVM limitation: at runtime, the JVM forgets the generic type parameter — List<String> and List<Int> are both just List at the bytecode level. This means you can't write if (value is T) or T::class in a generic function — the JVM has no idea what T is at runtime. The reified keyword solves this exclusively for inline functions: because inlining copies the function body to the call site where the actual type is known, the compiler can substitute the real type T at each call site. This is one of the most powerful (and uniquely Kotlin) features you won't find in Java.

// Problem: Type erasure — T is unknown at runtime in regular generics
fun <T> isType(value: Any): Boolean {
    return value is T  // ❌ ERROR: Cannot check for erased type T
}

// Solution: reified + inline — T is real type at call site
inline fun <reified T> isType(value: Any): Boolean {
    return value is T  // ✅ works! T is concrete at each call site
}
isType<String>("hello")  // true
isType<Int>("hello")     // false

// Real-world uses of reified:

// 1. Start Activity without passing Class
inline fun <reified T : Activity> Context.startActivity() {
    startActivity(Intent(this, T::class.java))
}
startActivity<HomeActivity>()  // clean, no ::class.java needed

// 2. Gson/Retrofit type-safe parsing
inline fun <reified T> Gson.fromJson(json: String): T =
    fromJson(json, T::class.java)

val user: User = gson.fromJson(jsonString)  // no Class parameter!

// 3. Find fragment by type
inline fun <reified T : Fragment> FragmentManager.findFragment(): T? =
    fragments.filterIsInstance<T>().firstOrNull()

// Why only inline: The compiler inlines the function body at each call site
// At each call site the actual type (String, User, etc.) is known at compile time
// The compiler replaces T with the actual class reference

Type erasure — the problem. On the JVM, generic type parameters exist only at compile time. At runtime, List<String>, List<Int>, and List<User> are all just List. This means you can't write value is T, can't call T::class.java, and can't use T in any runtime type check inside a normal generic function — the JVM has no idea what T is by then.

reified solves this for inline functions. Because an inline function's body is copied to each call site at compile time — where the actual type argument is still known — the compiler can substitute the real type before the bytecode is finalised. Mark the type parameter reified and the compiler treats T as a concrete, accessible type inside the function body: value is T and T::class.java both work.

Why only inline? Non-inline functions are compiled once into bytecode and called at runtime — by then, T is already erased. Inline functions are expanded before that happens, at each call site during compilation, at a point when the actual type is still known. Without inlining, reified is impossible. There's no workaround.

Practical uses in Android. Starting a typed Activity — inline fun <reified T : Activity> Context.startActivity() — is the most common example. Jetpack's by viewModels() delegate uses reified internally to capture the ViewModel class without the caller passing MyViewModel::class.java. Parsing JSON with inline fun <reified T> fromJson(json: String): T = gson.fromJson(json, T::class.java) eliminates the boilerplate Class parameter entirely.

is, as, and filterIsInstance. Inside a reified inline function, value is T and value as T are both valid. The standard library's filterIsInstance<T>() uses exactly this pattern to filter a list by runtime type. Outside of reified inline functions, these are compile errors.

What reified can't do. It gives you access to the type — not the ability to construct it. T() is still a compile error inside a reified function. For construction, pass a factory lambda: factory: () -> T, or use a class reference with reflection. Reified also can't be passed as a type argument to non-reified parameters.

💡 Interview Tip

Explain the mechanism, not just the effect: 'Type erasure means the JVM forgets generic type parameters at runtime — a List<String> and a List<Int> are both just List at runtime. reified works around this by inlining the function, so the compiler knows the actual type at the call site and injects it.' The practical example: inline fun <reified T> startActivity() — used in every Android navigation helper.

Mark it done or save it in the question bank · Back to top

HardMost asked

11. Explain delegation in Kotlin — class delegation and property delegation. What are built-in delegates?

Kotlin's by keyword enables two distinct patterns: class delegation and property delegation. Both express the same idea — composition over inheritance. Instead of subclassing to add behaviour, you delegate to another object. What would take 50 lines of manual boilerplate in Java becomes a single line in Kotlin.

Class delegation lets a class implement an interface by forwarding all its method calls to another object. The compiler generates every forwarding method automatically — you only need to override the ones you want to change. This is the Decorator pattern in one line: wrap an object, add your behaviour, and let everything else pass through.

interface Printer { fun print(text: String) }
class ConsolePrinter : Printer { override fun print(text: String) = println(text) }

// Without delegation — 20+ forwarding methods by hand
class LoggingPrinter(private val delegate: Printer) : Printer {
    override fun print(text: String) { log(text); delegate.print(text) }
}

// With delegation — compiler generates all forwarding methods
class LoggingPrinter(printer: Printer) : Printer by printer {
    // override only what you need to change — everything else delegates to printer
}

Property delegation extracts the get/set logic of a property into a reusable object. Kotlin wires it up at compile time — the delegate implements getValue() (and setValue() for mutable properties). Kotlin ships four built-in delegates that cover the most common patterns.

// lazy — runs once on first access, result cached forever
val db by lazy { Room.databaseBuilder(...).build() }

// observable — callback fires after every assignment
var name by Delegates.observable("initial") { _, old, new ->
    println("$old → $new")
}

// vetoable — return false to reject the new value
var age by Delegates.vetoable(0) { _, _, new -> new >= 0 }
age = 25   // ✅ accepted
age = -1   // ❌ rejected — age stays 25

// Custom delegate — implement getValue / setValue
class SharedPrefsDelegate(private val key: String, private val default: String) {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String =
        prefs.getString(key, default) ?: default
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) =
        prefs.edit().putString(key, value).apply()
}
var userName by SharedPrefsDelegate("user_name", "")

lazy { } is the most-used delegate in Android. The block runs once on first access and the result is cached forever. Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED), it's ideal for ViewBinding setup, repositories that need a context, or anything expensive that may not always be needed. Use PUBLICATION mode if multiple threads might initialise it concurrently, or NONE to skip synchronisation entirely when you know access is single-threaded.

Delegates.observable fires a callback after every assignment, passing the old and new value. It's useful for debugging, triggering side effects, or lightweight reactive patterns where a full StateFlow would be overkill.

Delegates.vetoable works like observable but gives you veto power — return false and the assignment is cancelled, leaving the property unchanged. Clean alternative to throwing a validation exception.

Custom delegates follow a simple contract: implement ReadOnlyProperty<R, T> or ReadWriteProperty<R, T>. This is exactly how Jetpack's by viewModels(), by navArgs(), and most SharedPreferences/DataStore helper libraries are built — they're all just property delegates reading from some backing store on first access.

💡 Interview Tip

The strongest answer ties delegation to real code you've shipped: by lazy for expensive one-time setup, by viewModels() and by navArgs() in Fragments (both ARE Kotlin delegates under the hood), and class delegation for the Decorator pattern. Mentioning that by navArgs() is just a property delegate — not magic — shows you understand the mechanism, not just the API surface.

Mark it done or save it in the question bank · Back to top

MediumMost asked

12. What is operator overloading in Kotlin? What operators can be overloaded?

Operator overloading lets you define what standard operators (+, -, *, /, [], in, ==, >, etc.) mean for your custom classes. Kotlin restricts overloading to a specific predefined set of operators — unlike C++ where you can overload almost anything. The key rule: operator functions must be marked with the operator modifier, and they must use specific function names that Kotlin maps to each operator. When done well, operator overloading makes code dramatically more readable — matrix1 + matrix2 is clearer than matrix1.add(matrix2). When done poorly, it makes code cryptic. The standard guidance: only overload operators when the operation is natural and obvious to the reader.

data class Vector(val x: Double, val y: Double) {

    // Arithmetic operators
    operator fun plus(other: Vector)  = Vector(x + other.x, y + other.y)  // v1 + v2
    operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)  // v1 - v2
    operator fun times(scalar: Double) = Vector(x * scalar, y * scalar)    // v1 * 2.0
    operator fun unaryMinus()         = Vector(-x, -y)                     // -v1

    // Comparison
    operator fun compareTo(other: Vector): Int {
        val mag1 = Math.sqrt(x * x + y * y)
        val mag2 = Math.sqrt(other.x * other.x + other.y * other.y)
        return mag1.compareTo(mag2)
    }  // v1 > v2, v1 < v2

    // Index operator
    operator fun get(index: Int) = when (index) { 0 -> x; 1 -> y; else -> throw IndexOutOfBoundsException() }
    // v[0] → x, v[1] → y
}

val v1 = Vector(1.0, 2.0)
val v2 = Vector(3.0, 4.0)
println(v1 + v2)    // Vector(x=4.0, y=6.0)
println(-v1)        // Vector(x=-1.0, y=-2.0)
println(v1 > v2)    // false
println(v1[0])      // 1.0

// invoke operator — makes object callable like a function
class Adder(val base: Int) {
    operator fun invoke(x: Int) = base + x
}
val add5 = Adder(5)
println(add5(3))  // 8 — object called like a function!

Arithmetic operators — plus (+), minus (-), times (*), div (/), rem (%) — are the most commonly overloaded. Unary variants include unaryMinus (-x), unaryPlus (+x), and not (!x). Use these for math types like Money, Vector, Matrix, or Fraction where the semantics are immediately obvious to any reader.

Comparison operators are all handled by implementing a single compareTo function that returns a negative, zero, or positive Int. Implementing Comparable<T> gives all four operators (<, >, <=, >=) plus automatic sort() support in one shot.

Equality flows through the == operator, which calls equals() under the hood. Data classes generate equals() automatically. For other classes, override both equals() and hashCode() together. Note that === always checks reference identity and ignores equals() entirely.

Index operator (get/set) — operator fun get(index: Int) and set(index: Int, value: T) — enables the obj[i] and obj[i] = value syntax. This is how Matrix[row][col] access works and how custom collections expose elements with natural bracket notation.

invoke makes an instance callable like a function: operator fun invoke() lets you write handler() instead of handler.call(). This pattern is used heavily for factory objects, function-like interfaces, and command patterns throughout Kotlin.

contains (in operator) — operator fun contains(element: T): Boolean — enables the natural if (item in collection) syntax. All Kotlin collections support it, and you can add it to custom domain types to express membership checks naturally.

rangeTo and rangeUntil enable the 1..10 and 1 until 10 syntax along with custom range creation. Combined with a contains operator and iterator, they let custom types work seamlessly in for loops and range checks.

💡 Interview Tip

The key principle to state: 'I overload operators only when the semantics are completely obvious to anyone reading the code — Point + Point makes sense, but User + User would be confusing.' The invoke operator is the most surprising — making instances callable like functions enables function-object patterns and DSLs. The contains operator (in) and get/set operators are very useful for domain-specific collections.

Mark it done or save it in the question bank · Back to top

MediumMost asked

13. What are Kotlin collections? Explain the difference between List, MutableList, Sequence, and when to use each.

Kotlin's collection system is built on a key design principle: separation of read-only and mutable interfaces. List is read-only (no add/remove); MutableList is mutable. This isn't about true immutability — a List variable might point to an ArrayList underneath — it's about expressing intent in your API. Sequences add a third dimension: lazy vs eager evaluation. A list operation like .map().filter().take() creates intermediate lists at every step. A sequence processes each element through the entire chain before moving to the next — no intermediate lists, dramatically more efficient for large datasets or long chains. Knowing when to use List vs Sequence is an important performance skill.

// Immutable collection interfaces — read-only view
val list: List<Int>       = listOf(1, 2, 3)       // cannot add/remove
val set:  Set<Int>        = setOf(1, 2, 3)        // unique, unordered
val map:  Map<String, Int> = mapOf("a" to 1)      // read-only key-value

// Mutable versions
val mList: MutableList<Int> = mutableListOf(1, 2, 3)
mList.add(4); mList.remove(1)

// SEQUENCE — lazy evaluation, processes one element at a time
// EAGER (List): processes entire collection at each step
val eagerResult = (1..1_000_000)
    .filter { it % 2 == 0 }     // processes ALL 1M → creates new list of 500K
    .map { it * 2 }              // processes ALL 500K → creates new list
    .first()                       // gets first element

// LAZY (Sequence): processes elements one at a time until done
val lazyResult = (1..1_000_000).asSequence()
    .filter { it % 2 == 0 }     // lazy — doesn't execute yet
    .map { it * 2 }              // lazy — doesn't execute yet
    .first()                       // executes: checks 1,2 → returns 4. Stops!

// Common collection operations
val names = listOf("Alice", "Bob", "Charlie")
names.groupBy { it.first() }       // {A=[Alice], B=[Bob], C=[Charlie]}
names.partition { it.length > 3 }  // Pair([Alice, Charlie], [Bob])
names.associate { it to it.length } // {Alice=5, Bob=3, Charlie=7}
names.zip(listOf(1, 2, 3))          // [(Alice,1), (Bob,2), (Charlie,3)]
names.flatMap { it.toList() }       // [A,l,i,c,e,B,o,b,...]

Read-only vs mutable interfaces — List, Set, and Map are read-only views with no add/remove/set methods. MutableList, MutableSet, and MutableMap layer on mutation. Prefer read-only interfaces in function signatures to prevent accidental mutation and communicate clearly that the caller cannot change the collection.

Read-only does not mean immutable. A List<T> variable might point to an ArrayList internally. If another reference holds the same object as a MutableList and mutates it, the List reference sees the change. For true immutability, use kotlinx.collections.immutable or defensively copy the collection — this distinction trips up many developers.

Eager evaluation is how standard collection operations (map, filter, flatMap) work — they process the entire collection immediately and return a new list at each step. Chaining three operations on 10,000 elements creates three intermediate lists. For small collections this is fine; for large datasets it wastes memory and time.

Sequences evaluate lazily — list.asSequence().map{}.filter{}.take(10) processes elements one-by-one through the entire chain without creating intermediate lists, stopping as soon as take(10) is satisfied. For a million-element list where you only need the first ten filtered items, this is dramatically faster and uses far less memory.

When not to use Sequences — for small collections under ~1000 elements, the per-element object allocation overhead can make sequences slower than eager evaluation. Stateful operations like sorted() and distinct() cannot benefit from laziness because they must see all elements before producing output. When in doubt, benchmark.

Array vs List — Array<T> is fixed-size and mutable, mapping to a JVM array. List grows via ArrayList underneath. For primitive types, use IntArray, LongArray, etc. — these compile to int[], long[] without boxing. Never use Array<Int>, which boxes every integer. In most Android code, prefer List; use arrays only for performance-critical paths or Java APIs that require them.

💡 Interview Tip

The sequence performance question is a favorite: 'When do you use sequences over collections?' — answer: when your pipeline has 3+ operations OR the collection has 1000+ elements. The key insight: collections evaluate eagerly (all elements at each step), sequences evaluate lazily (element by element through the whole pipeline). One concrete example: items.asSequence().filter{}.map{}.firstOrNull() stops at the first match — collections would process ALL items even if the first match is at index 0.

Mark it done or save it in the question bank · Back to top

MediumMost asked

14. What is the when expression in Kotlin? How is it different from Java's switch?

The when expression is Kotlin's supercharged replacement for Java's switch statement, and the upgrade is dramatic. Java's switch is limited to integers, strings, and enums, requires break statements to avoid fall-through, and can only be a statement (not return a value). Kotlin's when works with any type, has no fall-through, can match ranges and conditions, performs smart casts, and is an expression — meaning it can return a value and be used directly in assignments or as function arguments. When combined with sealed classes, it becomes exhaustive — the compiler forces you to handle every case, turning runtime bugs into compile-time errors.

// Basic when — replaces switch
when (x) {
    1    -> println("one")
    2, 3 -> println("two or three")   // multiple values
    in 4..10 -> println("four to ten")  // range check
    else  -> println("other")
}

// when as an expression (returns value)
val description = when (score) {
    in 90..100 -> "Excellent"
    in 70..89  -> "Good"
    in 50..69  -> "Average"
    else        -> "Below average"
}

// when with type checking (smart cast)
fun describe(obj: Any): String = when (obj) {
    is String -> "String of length ${obj.length}"  // smart cast to String
    is Int    -> "Int: $obj"
    is List<*> -> "List of ${obj.size} elements"
    else       -> "Unknown"
}

// when without argument — replaces if-else chain
val result = when {
    x < 0   -> "negative"
    x == 0  -> "zero"
    x > 0   -> "positive"
    else    -> "unreachable"
}

// Exhaustive when with sealed class (no else needed!)
when (val state = viewModel.state.value) {
    is UiState.Loading -> showLoader()
    is UiState.Success -> showData(state.data)  // state smart-cast
    is UiState.Error   -> showError(state.message)
}

when as an expression returns the value of the matched branch — val label = when (status) { Status.LOADING -> "Loading..." Status.SUCCESS -> "Done" else -> "Failed" }. When used as an expression, the else branch is required unless the compiler can prove exhaustiveness (sealed class or enum with all cases covered).

Type matching and smart casts — inside each is branch, the variable is automatically cast to the matched type with no explicit cast needed. This is the primary pattern for working with sealed class hierarchies: when (shape) { is Circle -> println(shape.radius) is Rectangle -> println(shape.width) }.

Range and condition matching — when branches can match ranges (in 90..100), multiple values (1, 2, 3 -> "small"), or arbitrary boolean expressions. This cleanly replaces Java's long chains of if-else if, especially when checking a single value against several possibilities.

when without an argument evaluates each branch as an independent boolean condition — when { x > 10 -> "big" x > 5 -> "medium" else -> "small" }. This replaces messy if-else if chains where conditions aren't all variations of the same value.

Exhaustive when with sealed classes is the killer feature: when used as an expression against a sealed class or interface, the compiler forces you to handle every subclass. No else branch needed, and adding a new subclass causes a compile error at every unhandled when site. This is how safe state machines are built — impossible to forget a state at runtime.

when vs if — use when when branching on a single value with multiple cases. Use if for simple binary conditions. In Jetpack Compose, when is the standard pattern for rendering UiState: when (uiState) { is Loading -> LoadingSpinner() is Success -> ContentList(uiState.data) is Error -> ErrorMessage(uiState.msg) }.

💡 Interview Tip

The sealed class + when combination is the most impressive answer: 'In every ViewModel I write, I use when (uiState) { is Loading, is Success, is Error } as an expression — the compiler forces me to handle all states, so adding a new state is a compile error, not a runtime bug.' Also highlight that when with no argument is a cleaner if-else chain — each branch can be an arbitrary boolean expression.

Mark it done or save it in the question bank · Back to top

MediumMost asked

15. What is destructuring in Kotlin? How do componentN functions work?

Destructuring declarations let you unpack an object's components into individual named variables in a single statement, making code more expressive and reducing the need for intermediate variables. val (name, age) = person is cleaner than val name = person.name; val age = person.age. Under the hood, destructuring is just syntactic sugar for calling componentN() functions — component1(), component2(), etc. Data classes auto-generate these. Any class can support destructuring by defining its own componentN() operators. The feature integrates deeply with for loops, lambda parameters, and function return values.

// data class auto-generates component1(), component2(), etc.
data class User(val name: String, val age: Int, val city: String)

val user = User("Rahul", 25, "Delhi")

// Destructuring — desugars to component calls
val (name, age, city) = user
// Equivalent to:
// val name = user.component1() → "Rahul"
// val age  = user.component2() → 25
// val city = user.component3() → "Delhi"

// Skip a component with _
val (nameOnly, _, cityOnly) = user  // skip age

// Destructuring in for loops
val users = listOf(User("Alice", 30, "Mumbai"), User("Bob", 25, "Pune"))
for ((name, age) in users) {
    println("$name is $age years old")
}

// Destructuring Map entries
val map = mapOf("one" to 1, "two" to 2)
for ((key, value) in map) { println("$key = $value") }

// Destructuring lambda parameters
val pairs = listOf(1 to "one", 2 to "two")
pairs.forEach { (num, word) -> println("$num = $word") }

// Custom componentN functions
class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}
val (x, y) = Point(3, 4)  // works!

// Pair and Triple support destructuring
val (first, second) = Pair("Hello", 42)

ComponentN convention — destructuring calls component1() for the first variable, component2() for the second, and so on in declaration order. Data classes generate these automatically for all primary constructor properties. For non-data classes, declare them yourself with the operator modifier: operator fun component1() = name.

Order matters and is a well-known pitfall. component1 maps to the first primary constructor property, component2 to the second. If you reorder properties in the constructor, every destructuring site silently receives wrong values — no compile error. Adding a property in the middle shifts all components downstream.

Underscore to skip components — val (_, age) = user skips the first component without creating an unused variable. The _ signals intentional omission to both the compiler and the reader. Use it whenever you only need some components from a pair or data class.

Destructuring in for loops is idiomatic for iterating Maps: for ((key, value) in map) uses Map.Entry's component1() and component2() directly. The same pattern works with lists of Pairs or data classes: for ((user, score) in userScores).

Destructuring in lambda parameters — map.forEach { (key, value) -> println("$key=$value") } — inline-destructures the lambda parameter when it is a single Pair or data class. The parentheses around (key, value) are required; without them you get a single undestructured parameter.

Returning multiple values is done by returning a data class or Pair: fun minMax(list: List<Int>): Pair<Int, Int> = list.min() to list.max(), then val (min, max) = minMax(list). Prefer a named data class over Pair for three or more values — it is self-documenting and avoids the silent-shift pitfall.

💡 Interview Tip

The gotcha interviewers love: 'Adding a new property to the beginning or middle of the primary constructor silently changes which value goes to which componentN — this breaks every destructuring of that class without a compile error.' Best practice: always use named variables in destructuring (val (id, name) = user → comment what each position is), or avoid destructuring altogether for data classes with 3+ properties. Also: _ can skip components you don't need.

Mark it done or save it in the question bank · Back to top

MediumMost asked

16. What are object, companion object, and anonymous objects in Kotlin?

Kotlin's object keyword creates singletons, companion objects, and anonymous objects — three related but distinct constructs. A top-level object declaration is a thread-safe, lazily-initialized singleton backed by a static INSTANCE field on the JVM. A companion object lives inside a class and serves as its "static" namespace — members are accessed via the class name. An anonymous object creates a one-off instance of an interface or abstract class without declaring a named class, similar to Java's anonymous inner classes.

// 1. Object declaration — Singleton
object AppConfig {
    val baseUrl = "https://api.myapp.com"
    var debugMode = false
    fun getHeaders() = mapOf("Accept" to "application/json")
}
AppConfig.baseUrl         // ✅ thread-safe singleton, lazy initialized
AppConfig.debugMode = true

// 2. Companion object — static members in a class
class User(val name: String) {
    companion object {  // can have a name: companion object Factory
        const val MAX_NAME_LENGTH = 50
        fun create(name: String): User? {
            return if (name.isNotBlank()) User(name) else null
        }
    }
}
User.create("Rahul")          // called on class, not instance
User.MAX_NAME_LENGTH           // 50

// @JvmStatic — makes companion method a true Java static
companion object {
    @JvmStatic
    fun newInstance() = MyFragment()
}
// Java: MyFragment.newInstance()  ← needs @JvmStatic

// 3. Object expression — anonymous object (Java's anonymous inner class)
val clickListener = object : View.OnClickListener {
    override fun onClick(v: View) { handleClick() }
}

// Anonymous object implementing multiple interfaces
val obj = object : Runnable, Closeable {
    override fun run() { }
    override fun close() { }
}

// Object with no supertype
val counter = object { var count = 0 }
counter.count++

object declaration creates a thread-safe, lazily-initialized singleton — no constructor call needed, just reference it by name. On the JVM it compiles to a class with a static INSTANCE field. Use it for app-wide singletons like AppConfig, Logger, or event buses.

companion object is Kotlin's replacement for static members — it lives inside a class and its members are accessed via the class name. There can only be one per class. Companion objects can implement interfaces, which enables the type-class and factory patterns that Java statics cannot support.

object expression creates an anonymous one-off object implementing an interface or abstract class inline — equivalent to Java's anonymous inner class. Use it for listener callbacks and small local implementations you don't need to name. Unlike object declarations, anonymous objects are not singletons.

@JvmStatic makes a companion object method a true static in the bytecode so Java callers can write MyFragment.newInstance() instead of MyFragment.Companion.newInstance(). Always add it to companion methods that Java code needs to call.

💡 Interview Tip

Explain the three distinct usages of object clearly. A common follow-up is "how do you expose a companion member as a true Java static?" — answer: @JvmStatic. Another good angle: discuss why Kotlin companions can implement interfaces while Java statics cannot, and how that enables more expressive factory patterns.

Mark it done or save it in the question bank · Back to top

MediumMost asked

17. How does Kotlin handle exceptions? What is the difference from Java?

Kotlin has no checked exceptions — all exceptions are unchecked, unlike Java where methods must declare or catch checked exceptions. This design choice reduces boilerplate and avoids the "swallowed exception" anti-pattern common in Java code. Kotlin also allows try to be used as an expression returning a value, and the standard library provides runCatching which wraps execution in a Result type for functional error handling without exceptions.

// No checked exceptions — no throws declaration needed
fun readFile(path: String): String {
    return File(path).readText()  // IOException — no @throws needed
}

// try-catch-finally — same as Java
try {
    val result = riskyOperation()
} catch (e: IOException) {
    log("IO error: ${e.message}")
} catch (e: Exception) {
    log("General error")
} finally {
    cleanup()
}

// try as an expression (returns value)
val number = try {
    parseInt(input)
} catch (e: NumberFormatException) {
    0  // default on parse error
}

// throw as an expression
val name = user?.name ?: throw IllegalStateException("User has no name")

// use() — auto-closeable, replaces try-with-resources
File("data.txt").bufferedReader().use { reader ->
    reader.readLines().forEach { println(it) }
}  // reader.close() called automatically, even on exception

// runCatching — functional exception handling
val result = runCatching { riskyOperation() }
    .getOrDefault("fallback")
    .onFailure { e -> log(e) }

// @Throws — for Java interoperability
@Throws(IOException::class)
fun readData(): String { return File("data.txt").readText() }

No checked exceptions means no forced try-catch and no throws declarations — all exceptions are unchecked. This eliminates the swallowed-exception anti-pattern common in Java (catching Exception and doing nothing), but it does require discipline to handle errors explicitly when needed.

try as an expression returns the value of the successful branch or the catch branch: val number = try { parseInt(input) } catch (e: NumberFormatException) { 0 }. This lets you assign parse results with a clean default on failure.

throw as an expression means throw has type Nothing — a subtype of every type — so it can appear in the Elvis operator: val name = user?.name ?: throw IllegalStateException("no name"). This composes naturally with null-safety operators.

use() is Kotlin's replacement for Java's try-with-resources. Any Closeable can call use { } and the resource is closed automatically, even if an exception is thrown, without the verbose try/finally boilerplate.

runCatching wraps a block in a Result<T> for functional error handling: runCatching { riskyOp() }.getOrDefault("fallback").onFailure { log(it) }. It keeps error handling composable without exceptions crossing suspend boundaries.

💡 Interview Tip

The interviewer wants to know whether you understand the trade-offs of no checked exceptions. Mention the @Throws annotation for Java interop and the Result/runCatching API for functional error handling. A great bonus: discuss how coroutine exception propagation differs between launch (propagates to parent) and async (re-thrown on await()).

Mark it done or save it in the question bank · Back to top

Hard

18. What are Kotlin Contracts? How do they help the compiler understand your code?

Kotlin Contracts are a compiler-hint mechanism (currently experimental) that lets library authors communicate additional guarantees about function behavior to the Kotlin compiler. Without contracts, the compiler cannot infer smart casts or variable initialization across lambda boundaries. Contracts express facts like "this lambda is called exactly once" (callsInPlace) or "if this function returns, the argument was non-null" (returnsNotNull), enabling the compiler to perform analysis it would otherwise conservatively refuse.

// Problem: compiler doesn't know that after isValid(), user is non-null
fun isValid(user: User?): Boolean = user != null && user.name.isNotEmpty()

val user: User? = getUser()
if (isValid(user)) {
    user.doSomething()  // ❌ Error: user is still User?
}

// Contract — tell the compiler: "if I return true, user is not null"
@OptIn(ExperimentalContracts::class)
fun isValid(user: User?): Boolean {
    contract {
        returns(true) implies (user != null)
    }
    return user != null && user.name.isNotEmpty()
}

if (isValid(user)) {
    user.doSomething()  // ✅ compiler knows user is User now
}

// callsInPlace — tells compiler lambda runs exactly once
@OptIn(ExperimentalContracts::class)
inline fun runOnce(block: () -> Unit) {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    block()
}

// Without contract: val must be initialized before use
val result: String
runOnce { result = "hello" }  // ✅ compiler knows this runs exactly once
println(result)                 // ✅ compiler knows result is initialized

// Stdlib uses contracts internally:
// also, apply, let, run, with — all have callsInPlace contracts
val x: Int
run { x = 42 }  // ✅ run has callsInPlace(EXACTLY_ONCE) contract
println(x)       // ✅ compiler knows x is definitely initialized

Contracts provide the compiler with behavioral guarantees about a function's execution that it cannot infer on its own. Without them, the compiler conservatively refuses smart casts and definite-assignment analysis across lambda and function-call boundaries.

returns(true) implies lets you tell the compiler "if this function returns true, then this condition holds." This enables smart casts after a custom validation function — the compiler can narrow the type of a variable past a call site it would otherwise treat as opaque.

callsInPlace(EXACTLY_ONCE) tells the compiler that a given lambda parameter is guaranteed to be invoked exactly once. This enables definite assignment inside the lambda — val x: String; runOnce { x = "hello" }; println(x) — the compiler knows x is initialized after the block.

The standard library's scope functions (let, run, apply, also, with) all use callsInPlace contracts internally, which is why val initialization inside run { } or apply { } is accepted by the compiler without complaint.

Still experimental API, but extremely stable in practice and used throughout kotlinx and the stdlib. Writing custom contracts is uncommon in application code, but understanding why they exist explains behaviors that would otherwise seem like compiler magic.

💡 Interview Tip

Most Android developers haven't written custom contracts, but showing you understand why they exist sets you apart. Explain that require(x != null) enabling a smart cast is powered by a contract, then describe callsInPlace and the val initialization use case — that's a concrete, memorable example interviewers appreciate.

Mark it done or save it in the question bank · Back to top

MediumMost asked

19. What is typealias in Kotlin and when is it useful?

A typealias introduces an alternative name for an existing type, making complex type signatures more readable without creating a new type. It is a purely compile-time construct — after compilation, the alias is replaced with the original type in bytecode, so there is zero runtime overhead. Common uses include shortening long generic types, aliasing function types, and making domain intent clearer, though it provides no type safety since the alias and original are interchangeable.

// Simplify complex types
typealias UserId = String
typealias UserMap = Map<UserId, User>
typealias Callback<T> = (Result<T>) -> Unit

// Function types
typealias OnClick = (View) -> Unit
typealias Predicate<T> = (T) -> Boolean

fun setClickListener(listener: OnClick) { }  // cleaner than (View) -> Unit

// Android use cases
typealias ViewClickListener = View.OnClickListener
typealias NetworkCallback = (Result<User>) -> Unit

// Avoid class name conflicts
import com.myapp.Date as AppDate
typealias JavaDate = java.util.Date

// IMPORTANT: typealias is NOT a new type
typealias UserId = String
val userId: UserId = "123"
val name: String = userId  // ✅ interchangeable — same type
// For true new types (prevents mixing), use inline/value classes:
@JvmInline
value class UserId(val value: String)  // TRUE new type, not interchangeable with String

typealias is purely a compile-time renaming — zero runtime overhead, zero new type created. After compilation, every use of the alias is replaced with the original type in bytecode. It is a readability tool, nothing more.

Common uses include shortening long generic types (typealias UserMap = Map<UserId, User>), naming function types (typealias OnClick = (View) -> Unit), avoiding class name conflicts between packages, and making domain intent visible in signatures without any cost.

typealias does not create type safety. UserId and String are completely interchangeable — the compiler accepts one wherever the other is expected. If you accidentally pass a raw String where a UserId is wanted, there is no error. For that level of protection, reach for a value class instead.

Generic type parameters are supported: typealias Predicate<T> = (T) -> Boolean gives a reusable name to a parameterized function type, making signatures like fun <T> filter(list: List<T>, pred: Predicate<T>) clean and readable.

💡 Interview Tip

The key contrast to know is typealias vs value class. Lead with "typealias is purely a readability tool — no new type, no type safety, no runtime cost" and follow with "if I need the compiler to prevent me from mixing a UserId with a plain String, I reach for a value class instead." That shows you understand both tools and know when to choose each.

Mark it done or save it in the question bank · Back to top

Hard

20. What are value classes (inline classes) in Kotlin? When should you use them?

Value classes (formerly called inline classes) are single-property wrappers annotated with @JvmInline value class. The compiler inlines the underlying value at call sites where possible, avoiding object allocation — making them a zero-overhead abstraction. They are most valuable for the "domain primitive" pattern, where you wrap primitive types like String or Int in named wrappers to get type safety, preventing errors like mixing a UserId with an OrderId even though both are strings at runtime.

// @JvmInline value class — wraps a single value
@JvmInline
value class UserId(val value: String)

@JvmInline
value class Email(val value: String) {
    init { require(value.contains("@")) { "Invalid email" } }
    val domain: String get() = value.substringAfter("@")
}

// Type safety — prevents mixing String with UserId/Email
fun sendEmail(userId: UserId, email: Email) { }

val id = UserId("user_123")
val mail = Email("rahul@example.com")
sendEmail(id, mail)  // ✅
// sendEmail(mail, id)  ❌ compile error — types don't match!
// sendEmail("user_123", "rahul@example.com")  ❌ must wrap in value class

// No runtime overhead — compiled to underlying type
// val id: UserId = UserId("123")  →  compiled as  val id: String = "123"
// Wrapper object only created when boxing is required (generics, nullable)

// Stable since Kotlin 1.5 — use in production
// Restrictions:
// ❌ Cannot have init block modifying the value (can validate)
// ❌ Cannot have backing fields other than the primary property
// ❌ Cannot inherit from classes (can implement interfaces)

// Real Android use case — avoid primitive type confusion
@JvmInline value class Dp(val value: Float)
@JvmInline value class Sp(val value: Float)
fun setTextSize(size: Sp) { }
fun setMargin(margin: Dp) { }
// Can't accidentally pass Sp where Dp is expected!

Value classes add compile-time type safety at zero runtime cost in the common case. The compiler inlines the wrapped value at call sites, so UserId("123") typically compiles to just the String "123" — no wrapper object allocated.

Prevents stringly-typed APIs — UserId and Email are genuinely distinct types even though both wrap String. The compiler rejects passing an Email where a UserId is expected, catching an entire class of mix-up bugs before they reach production.

Boxing does occur when a value class is used as a generic type argument or as a nullable value class instance. In those cases the wrapper object is created. This is the key boxing gotcha to mention: List<UserId> boxes every element, while a direct UserId parameter does not.

Value classes can have computed properties, methods, and implement interfaces — they are not just thin wrappers. The Email example with an init block validation and a domain computed property shows how much logic you can pack in without any runtime overhead on the happy path.

Jetpack Compose uses value classes extensively for Dp, Sp, Color, Alignment, and more — which is why you cannot accidentally pass a Dp where an Sp is expected in Compose layouts, despite both wrapping a Float.

💡 Interview Tip

Connect value classes directly to Android: Jetpack Compose uses them extensively (e.g., Dp, Color). Mention the boxing gotcha with generics and nullable types, and contrast with typealias — "value class gives compile-time type safety; typealias is just readability." A great follow-up answer: describe using value class to wrap an API token so it can't be accidentally passed to a logging function that accepts String.

Mark it done or save it in the question bank · Back to top

MediumMost asked

21. What are visibility modifiers in Kotlin? How is internal different from Java's package-private?

Kotlin has four visibility modifiers: public (default), private, protected, and internal. The notable addition over Java is internal, which limits visibility to the same compilation module rather than Java's package-private (same package). This makes internal ideal for multi-module Android projects where you want to expose APIs within a feature module but not to other modules. Default visibility is public, the opposite of Java where package-private is the implicit default.

// Kotlin visibility modifiers (top-level declarations)
public    // visible everywhere (default if not specified)
internal  // visible within the same MODULE only
private   // visible in the same file only (for top-level)

// For class members
public    // visible everywhere
protected // visible in class and subclasses (not top-level)
internal  // visible in same module
private   // visible in the same class only

// INTERNAL — key difference from Java
// Java: package-private = visible within same PACKAGE
// Kotlin: internal = visible within same MODULE (Gradle module)

// In a multi-module Android app:
// :feature:home module
internal class HomeRepositoryImpl : HomeRepository  // can't leak to :app
internal fun parseHomeData(json: String): HomeData { }

// :core:network module
class ApiClient {
    internal val httpClient = OkHttpClient()  // hidden from :feature modules
    fun getUser(id: String): User { }         // public API
}

// private class member
class UserViewModel {
    private val _state = MutableStateFlow(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()  // expose read-only
}

// Smart: internal constructor with public factory
class Database internal constructor() {
    companion object {
        fun create(): Database = Database()  // factory controls creation
    }
}

public is the default in Kotlin — the opposite of Java's implicit package-private. If you don't specify a modifier, the declaration is visible everywhere. This means you must opt into restriction rather than opt into exposure.

internal is Kotlin's key addition — it limits visibility to the same Gradle module. Java has no equivalent; its closest analog is package-private, which is limited to the same package. internal lets you expose rich APIs inside a feature module while keeping implementation classes invisible to the rest of the app.

Multi-module architecture is where internal shines: mark repository implementations and parsing helpers as internal so the :app module can only interact with the public interface, enforcing clean boundaries at compile time without runtime checks.

The _state / state pattern — private val _state = MutableStateFlow(...) paired with val state: StateFlow<...> = _state.asStateFlow() — exposes a read-only view of mutable state. This is idiomatic Kotlin and demonstrates visibility modifiers working together to protect internal mutation.

💡 Interview Tip

The question often targets internal vs package-private and how it helps in multi-module architecture. Frame your answer around a real Android example: "In a clean-architecture project, my :data module exposes Repository interfaces as public but keeps implementation classes internal so the :app module must go through the interface — enforcing the boundary at compile time."

Mark it done or save it in the question bank · Back to top

Hard

22. How do you build a DSL in Kotlin? What language features make this possible?

Kotlin's combination of lambdas with receivers, extension functions, operator overloading, and infix functions makes it exceptionally well-suited for building internal DSLs — APIs that read like a domain-specific language while remaining valid Kotlin. The core mechanism is the function type with receiver: Type.() -> Unit, which lets a lambda body access the receiver's members without qualification. Jetpack libraries like Compose, the Navigation DSL, and Gradle's Kotlin DSL all rely heavily on this pattern.

// DSL for building HTML
class HtmlBuilder {
    private val children = StringBuilder()

    fun div(block: HtmlBuilder.() -> Unit) {  // lambda with receiver
        children.append("<div>")
        HtmlBuilder().apply(block).also { children.append(it.build()) }
        children.append("</div>")
    }

    fun text(value: String) { children.append(value) }
    fun build() = children.toString()
}

fun html(block: HtmlBuilder.() -> Unit): String = HtmlBuilder().apply(block).build()

// Usage — reads like declarative config
val page = html {
    div { text("Hello World") }
}

// Android DSL examples you use every day:

// Gradle Kotlin DSL
dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    testImplementation("junit:junit:4.13")
}

// NavGraph DSL
NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("detail/{id}") { DetailScreen() }
}

// Jetpack Compose IS a DSL!
@Composable
fun MyScreen() {
    Column {            // lambda with receiver: ColumnScope
        Text("Hello")
        Button(onClick = {}) { Text("Click") }
    }
}

// Key features enabling Kotlin DSLs:
// 1. Lambdas with receivers: Type.() -> Unit
// 2. Extension functions: add methods to existing types
// 3. Operator overloading: custom operators
// 4. infix functions: natural language syntax
// 5. Named/default arguments: cleaner call sites

Lambda with receiver (Type.() -> Unit) is the core mechanism. Inside the lambda, this refers to the receiver type, so you can call its members without qualification. This is what makes html { div { text("Hello") } } work — each block has a different implicit this pointing to its builder.

Extension functions add DSL methods to any existing type without subclassing. This is how Gradle's dependencies { implementation(...) } works — implementation is an extension function on DependencyHandlerScope, not a method on any concrete class.

DSLs you already use every day — Gradle build files, Jetpack Compose layouts, the Navigation DSL, Ktor route definitions, and kotlinx.html are all built on these same patterns. Recognizing this makes you a better consumer and a capable author of DSLs.

Jetpack Compose is fundamentally a DSL. Column { Text("Hello") Button(onClick={}) { Text("Click") } } is just Kotlin — Column takes a lambda with a ColumnScope receiver, and the @Composable functions inside it are normal function calls. Understanding this demystifies Compose completely.

@DslMarker prevents scope leakage — without it, code inside an inner DSL block can accidentally call methods from an outer scope, creating confusing and error-prone APIs. Annotating your DSL classes with a shared @DslMarker annotation lets the compiler catch this at compile time.

💡 Interview Tip

Anchor your answer in real Jetpack examples — Compose's Column { Text(...) } or the Navigation DSL — to show the concept isn't academic. Then explain the mechanics: lambda with receiver enables the block, @DslMarker prevents scope leakage. If asked to write a DSL, a simple HTML builder or a Retrofit-style request builder demonstrates the pattern clearly in a few lines.

Mark it done or save it in the question bank · Back to top

MediumMost asked

23. What is Kotlin's type system? Explain Any, Unit, Nothing, and how they differ from Java's Object, void, Void.

Kotlin's type system includes three special types that define the top and bottom of the type hierarchy. Any is the root supertype of all non-nullable Kotlin types (equivalent to Java's Object). Unit is Kotlin's equivalent of void, but it is a real type with a single instance — enabling functions to be used in generic contexts. Nothing is the bottom type: a subtype of every type with no instances, used for functions that never return normally, enabling powerful exhaustiveness and smart-cast analysis.

// Any — top type, supertype of ALL Kotlin types
// Like Java's Object but with better semantics
fun process(value: Any) { }  // accepts any non-null type
fun process(value: Any?) { } // accepts anything including null

// Any vs Object:
// Any doesn't have: wait(), notify(), notifyAll(), getClass()
// Any has: equals(), hashCode(), toString() (same as Object)

// Unit — return type for functions that return no meaningful value
// Like Java's void BUT Unit is a real type with a single value: Unit
fun logMessage(msg: String): Unit { println(msg) }  // : Unit is optional

// Unit can be used as generic argument (void cannot)
val callback: () -> Unit = { println("done") }
fun runCallback(cb: () -> Unit) { cb() }

// Nothing — bottom type, subtype of ALL types
// Functions that NEVER return normally
fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}
fun loop(): Nothing { while (true) { } }

// Nothing enables smart analysis:
val name: String = user?.name ?: throw Exception("null")
// throw returns Nothing (subtype of String) → assignment type-checks!

// Nothing? — the only value is null
val nothing: Nothing? = null

// Kotlin primitive types — look like objects, compiled to JVM primitives
val x: Int = 5        // compiled to int x = 5 (JVM primitive)
val y: Int? = null    // compiled to Integer y = null (boxed)
val list: List<Int>  // compiled to List<Integer> (boxing required for generics)

Any is the root supertype of all non-nullable Kotlin types — every class implicitly extends Any. It has equals(), hashCode(), and toString() like Java's Object, but strips out the threading methods (wait, notify) that don't belong in a type hierarchy root.

Unit is a real type with a single instance, returned by functions that produce no meaningful value. Unlike Java's void, Unit can be used as a generic type argument — Flow<Unit> is a valid event stream, and () -> Unit is a valid function type for callbacks. This is impossible with Java's void.

Nothing is the bottom type — a subtype of every type, with no instances. Functions that always throw or loop forever return Nothing. Because Nothing is a subtype of String, Boolean, and everything else, throw can appear in an Elvis operator or when branch and the expression still type-checks correctly.

Kotlin primitive types like Int, Long, and Boolean look like objects but compile to JVM primitives (int, long, boolean) when possible. The nullable version Int? compiles to boxed Integer. This means you get clean Kotlin syntax with no performance sacrifice for non-nullable primitives.

💡 Interview Tip

Most candidates know Unit ≈ void but miss the key insight that it's a real type. The most impressive thing to say is: "Because Unit is a real singleton, you can use it in generic contexts like Flow<Unit> for event streams — you can't do that with Java's void." For Nothing, the smart-cast and when-exhaustiveness angle is what separates strong Kotlin developers from average ones.

Mark it done or save it in the question bank · Back to top

MediumMost asked

24. What are the key advantages of Kotlin over Java for Android development?

Kotlin offers comprehensive improvements over Java for Android development across safety, conciseness, expressiveness, and concurrency. Google made Kotlin the preferred language for Android in 2019 and all new Jetpack APIs are Kotlin-first, with many (Compose, Flow, coroutines) unusable or awkward in Java. Key advantages include null safety baked into the type system, coroutines for structured concurrency, data classes, extension functions, and a more expressive type system — all reducing boilerplate and common bug classes.

// 1. NULL SAFETY — eliminates NPE at compile time
// Java: NullPointerException is the most common runtime crash
val name: String? = getUserName()   // explicit nullable
val length = name?.length ?: 0      // safe, no NPE

// 2. CONCISENESS — data class vs Java POJO
// Java: ~50 lines with constructor, getters, equals, hashCode, toString
data class User(val id: String, val name: String)  // 1 line!

// 3. COROUTINES — sequential async code
// Java: nested callbacks (callback hell)
// Kotlin: sequential, readable async code
suspend fun loadData(): Data {
    val user = fetchUser()     // async, looks sync
    val posts = fetchPosts(user)
    return Data(user, posts)
}

// 4. EXTENSION FUNCTIONS — no more Utils classes
fun Context.showToast(msg: String) = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
// Java: ToastUtils.showToast(context, message);

// 5. SMART CASTS — no explicit casting
if (animal is Dog) {
    animal.bark()  // ✅ auto-cast to Dog, no (Dog) cast needed
}

// 6. NO CHECKED EXCEPTIONS — cleaner code
// Java: throws IOException, InterruptedException everywhere
fun readFile() = File("data.txt").readText()  // no throws needed

// 7. DEFAULT PARAMETERS — no overloading pyramid
fun connect(host: String, port: Int = 80, timeout: Int = 5000) { }
connect("example.com")           // uses defaults
connect("example.com", timeout = 3000)  // named args

// 8. 100% JAVA INTEROPERABILITY
// Can use all Java libraries, call Java from Kotlin and vice versa

Null safety baked into the type system eliminates the #1 cause of Android crashes — NullPointerException. Every type is either nullable (String?) or non-nullable (String), and the compiler enforces safe access through ?., ?:, and !!. Java's Optional is verbose and opt-in; Kotlin's null safety is total and mandatory.

Conciseness reduces boilerplate dramatically. A data class replaces fifty lines of Java POJO (constructor, getters, equals, hashCode, toString). Extension functions replace utility classes. Scope functions (let, apply, run, also, with) replace verbose temporary-variable patterns. Less code means fewer bugs.

Coroutines transform async programming from nested callback pyramids into sequential, readable code. Retrofit + Handler callback hell becomes suspend fun loadData(): Data { val user = fetchUser(); val posts = fetchPosts(user); return Data(user, posts) } — three lines of sequential logic that runs asynchronously.

Smart casts eliminate explicit casting boilerplate. After an is check, the compiler knows the type and applies it automatically — if (animal is Dog) animal.bark() needs no (Dog) cast. Combined with when expressions and sealed classes, this makes type-driven logic both safe and concise.

Full Java interoperability means every existing Java library, framework, and API works out of the box. There is no migration cliff — you can mix Kotlin and Java files in the same module, convert incrementally, and continue using all of the Android SDK and third-party libraries unchanged.

💡 Interview Tip

Don't just list features — connect each to a concrete Android pain point it solves. "Coroutines replaced the 3-level callback nesting we had in our Retrofit+Handler code," or "data classes prevented a production bug where a new field added to our model broke the HashMap lookup because hashCode wasn't updated." Concrete impact beats a feature checklist.

Mark it done or save it in the question bank · Back to top

Hard

25. What is Kotlin Serialization? How does it compare to Gson and Moshi?

Kotlin Serialization (kotlinx.serialization) is a compile-time, annotation-driven serialization library that generates serializer code at compile time using a Kotlin compiler plugin, avoiding runtime reflection. Gson and Moshi rely on reflection (Gson) or annotation processing/reflection (Moshi) at runtime. Kotlin Serialization is the natural choice for Kotlin-first projects, offering multiplatform support, better performance, full Kotlin type system awareness (including sealed classes and nullable types), and compatibility with Kotlin's value classes.

// Setup
// plugins { id("org.jetbrains.kotlin.plugin.serialization") }
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

// @Serializable — annotation-based, compile-time code generation
@Serializable
data class User(
    val id: Int,
    val name: String,
    @SerialName("email_address")  // custom JSON key name
    val email: String,
    val role: String = "user"  // optional with default
)

// Encode to JSON
val user = User(1, "Rahul", "rahul@app.com")
val json = Json.encodeToString(user)
// {"id":1,"name":"Rahul","email_address":"rahul@app.com","role":"user"}

// Decode from JSON
val decoded: User = Json.decodeFromString(json)

// Configure JSON behavior
val json = Json {
    ignoreUnknownKeys = true   // don't crash on extra fields
    isLenient = true           // allow unquoted strings
    prettyPrint = true         // formatted output
    coerceInputValues = true   // use defaults for nulls
}

// Use with Retrofit
val retrofit = Retrofit.Builder()
    .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
    .build()

// Sealed class serialization
@Serializable
sealed class Shape {
    @Serializable data class Circle(val radius: Double) : Shape()
    @Serializable data class Rectangle(val w: Double, val h: Double) : Shape()
}

Compile-time code generation is the defining advantage. Kotlin Serialization uses a compiler plugin to generate serializer code at build time, so there is zero reflection at runtime. Gson relies entirely on reflection; Moshi can use codegen but falls back to reflection. No reflection means faster startup, smaller APK after R8, and no keep rules needed.

Null safety awareness sets it apart from Gson. Kotlin Serialization knows your Kotlin types and will refuse to deserialize a null JSON value into a non-nullable field — it throws a descriptive exception instead of silently crashing downstream. Gson does not understand Kotlin's null system and happily assigns null to non-nullable properties.

Multiplatform support means the same @Serializable classes and serialization logic work on Android, iOS (via Kotlin/Native), and Kotlin/JS — with no changes. Gson and Moshi are JVM-only. For Kotlin Multiplatform projects, kotlinx.serialization is the only real choice.

Rich type system support — sealed classes, generic types, nested serialization, and custom serializers all work out of the box. Serializing a sealed class hierarchy with a type discriminator requires a single @Serializable annotation; in Gson it requires a custom TypeAdapterFactory with significant boilerplate.

💡 Interview Tip

Frame your answer around three axes: performance (compile-time vs reflection), correctness (null safety awareness, sealed classes), and portability (KMP). The ProGuard/R8 safety point is highly practical and shows production experience — many developers have debugged mysterious release-only JSON crashes caused by Gson and obfuscation. Mentioning that Moshi with codegen is also a good choice (not just "Gson is bad") shows balanced judgment.

Mark it done or save it in the question bank · Back to top

MediumMost asked

26. What is the difference between == and === in Kotlin?

Kotlin has two equality operators that serve very different purposes — a common interview trap for developers coming from Java.

// == Structural equality — calls equals()
val a = String("hello".toCharArray())
val b = String("hello".toCharArray())
println(a == b)   // true  — same content
println(a === b)  // false — different objects

// === Referential equality — same object in memory
val c = a
println(a === c)  // true — same reference

// data class uses == for structural equality
data class User(val name: String)
val u1 = User("Rahul")
val u2 = User("Rahul")
println(u1 == u2)   // true  — data class equals()
println(u1 === u2)  // false — different objects

// null-safe: a == null compiles to a?.equals(null) ?: (null === null)
val x: String? = null
println(x == null)  // true — safe, no NPE

== checks structural equality by calling equals() under the hood. Two different objects with the same content will be == equal if equals() says so — data classes do this automatically for all primary constructor properties.

=== checks referential equality — it returns true only when both variables point to the exact same object in memory. It always ignores equals() and cannot be overridden.

== is null-safe — null == null is true, and x == null never throws NPE regardless of x's value. Java's x.equals(null) throws if x is null. This is one of the most practical daily differences between the two languages.

💡 Interview Tip

Emphasize null safety: == in Kotlin is null-safe — null == null is true without NPE. In Java, null.equals(anything) throws. Also mention data classes: == compares all primary constructor properties, so two different User objects with the same id and name are equal — this is the default you want for value objects.

Mark it done or save it in the question bank · Back to top

MediumMost asked

27. What are Kotlin's string templates and multiline strings?

Kotlin provides powerful string features that eliminate messy concatenation and make working with structured text much cleaner than Java.

// String templates — embed expressions with $
val name = "Rahul"
val age = 25
println("Name: $name, Age: $age")
println("Next year: ${age + 1}")  // expressions in {}
println("Length: ${name.length}")

// Multiline strings — trimIndent() removes common indent
val json = """
    {
        "name": "$name",
        "age": $age
    }
""".trimIndent()

// trimMargin() — custom margin prefix
val query = """
    |SELECT *
    |FROM users
    |WHERE age > $age
""".trimMargin()

// Raw strings — no escape sequences needed
val regex = """\d{3}-\d{4}"""  // no \\ needed
val path  = """C:\Users\Rahul\Documents"""

// String functions
"hello world".capitalize()      // "Hello world"
"  hello  ".trim()             // "hello"
"hello".repeat(3)              // "hellohellohello"
"hello".reversed()             // "olleh"
"a,b,c".split(",")            // [a, b, c]

String templates embed variables with $ and arbitrary expressions with ${}. They replace Java's verbose string concatenation with clean, readable inline substitution — "Hello $name, you are ${age + 1} next year" is far more maintainable than the concatenated equivalent.

Triple-quoted strings span multiple lines and require no escape sequences — backslashes, quotes, and newlines all appear literally. Combined with trimIndent(), they are ideal for test fixtures, SQL queries, JSON templates, and regex patterns where escaping would otherwise obscure the intent.

trimMargin() is the more surgical variant — it strips everything up to a specified margin character (default |) on each line, letting you indent the triple-quoted block with your code without that indentation ending up in the string value.

💡 Interview Tip

String templates ("Hello $name" and "${user.name.uppercase()}") eliminate Java's verbose + concatenation. Multiline strings with trimIndent() are invaluable for test fixtures, SQL queries, and JSON templates in unit tests — mention this practical use case to stand out.

Mark it done or save it in the question bank · Back to top

MediumMost asked

28. What is the difference between List and Array in Kotlin?

Both hold ordered elements but differ in mutability model, performance, and API richness. Knowing when to use each is a common interview question.

// Array — fixed size, mutable content, maps to JVM array
val array = arrayOf(1, 2, 3)
array[0] = 10          // ✅ can mutate elements
// array size is fixed — cannot add/remove
println(array.size)    // 3

// Primitive arrays — no boxing, better performance
val ints  = intArrayOf(1, 2, 3)   // int[] in JVM
val longs = longArrayOf(1L, 2L)  // long[] in JVM

// List — immutable interface, rich API
val list = listOf(1, 2, 3)
// list[0] = 10  ❌ read-only

// MutableList — dynamic size, full mutation
val mList = mutableListOf(1, 2, 3)
mList.add(4)      // ✅ grow
mList.remove(1)   // ✅ shrink
mList[0] = 10     // ✅ mutate

// Key differences:
// Array: fixed size, mutable elements, maps to JVM array[]
// List:  immutable interface, rich functional API (map/filter/etc)
// MutableList: dynamic size, mutable, still has full collection API

// Convert between them
val fromArray = array.toList()
val fromList  = list.toTypedArray()

Array is fixed-size and mutable — you can change elements but not add or remove them. It compiles directly to a JVM array (int[], Object[], etc.), making it the right choice for performance-critical code or Java APIs that require arrays.

List is the default choice for most business logic. It is an immutable interface backed by ArrayList, offering a rich functional API (map, filter, groupBy, etc.) and expressing read-only intent in function signatures.

MutableList adds dynamic sizing — add(), remove(), and element reassignment. Use it when the collection's contents genuinely need to change at runtime, but prefer exposing it via a read-only List interface to callers.

IntArray, LongArray, FloatArray — for primitive-heavy code, always prefer these over Array<Int>. They compile to int[], long[], float[] with no boxing overhead. Array<Int> compiles to Integer[] and boxes every single value, which matters in tight loops and large datasets.

💡 Interview Tip

The key distinction: List is an immutable interface backed by ArrayList (size can't change, elements can't be reassigned); Array is a fixed-size Java primitive array. For most Android code, prefer List/MutableList. Use Array only when interacting with Java APIs that require arrays or for performance-critical typed primitive arrays (IntArray, FloatArray).

Mark it done or save it in the question bank · Back to top

MediumMost asked

29. How does Kotlin interoperate with Java? What are @JvmStatic, @JvmField, @JvmOverloads?

Kotlin is 100% interoperable with Java but some Kotlin features need annotations to work cleanly from Java code. These annotations are commonly asked in Android interviews.

// @JvmStatic — expose companion function as true Java static
class MyFragment : Fragment() {
    companion object {
        @JvmStatic
        fun newInstance(id: String) = MyFragment().apply {
            arguments = bundleOf("id" to id)
        }
    }
}
// Java: MyFragment.newInstance("123")  ← needs @JvmStatic
// Without it: MyFragment.Companion.newInstance("123")

// @JvmField — expose property as Java field (no getter/setter)
class Config {
    @JvmField var timeout = 5000
}
// Java: config.timeout = 3000  ← direct field access
// Without it: config.setTimeout(3000)  ← through getter/setter

// @JvmOverloads — generate Java overloads for default params
@JvmOverloads
fun connect(host: String, port: Int = 80, timeout: Int = 5000) { }
// Java gets: connect(host), connect(host,port), connect(host,port,timeout)

// @Throws — declare checked exceptions for Java callers
@Throws(IOException::class)
fun readFile(): String = File("data.txt").readText()

// Calling Java from Kotlin — seamless
val list = ArrayList<String>()  // Java class
list.add("hello")               // Java method

@JvmStatic makes a companion object method a true Java static method in bytecode. Without it, Java callers must write MyFragment.Companion.newInstance() — awkward and easy to forget. With it, MyFragment.newInstance() works exactly as expected.

@JvmField exposes a Kotlin property as a public Java field, skipping getter/setter generation. Java callers get config.timeout = 3000 direct field access instead of config.setTimeout(3000). Use it for public constants and fields intended for direct Java access.

@JvmOverloads generates Java overload methods for each combination of default parameters. A Kotlin function with three default parameters gets four Java overloads, letting Java callers use whichever combination they need without named arguments.

@Throws declares checked exceptions in the bytecode so Java callers are required to handle or declare them. Without it, Java sees Kotlin functions as throwing no checked exceptions — which can hide errors from Java code that relies on exception declarations.

Calling Java from Kotlin is seamless — no annotations required in that direction. The one caveat is platform types: Java's unannotated return types appear as String! in Kotlin, bypassing null safety until you add @NonNull or @Nullable annotations to the Java side.

💡 Interview Tip

The three annotations have distinct purposes: @JvmStatic makes companion members true Java static methods; @JvmField exposes a property as a public Java field (no getter/setter); @JvmOverloads generates Java overloads for functions with default parameters. Mention platform types — Java's unannoted return types become String! in Kotlin, bypassing null safety until you add @NonNull/@Nullable.

Mark it done or save it in the question bank · Back to top

MediumMost asked

30. What are named and default arguments in Kotlin? How do they reduce boilerplate?

Named and default arguments dramatically reduce function overloads and make call sites self-documenting. They're one of the most practical Kotlin features for Android development.

// Default arguments — reduce overload pyramid
fun createUser(
    name: String,
    age: Int = 0,
    role: String = "user",
    active: Boolean = true
): User = User(name, age, role, active)

// Call with any combination
createUser("Rahul")                           // all defaults
createUser("Rahul", age = 25)               // skip role, active
createUser("Rahul", role = "admin")         // skip age
createUser("Rahul", 25, "admin", false)    // all explicit

// Named arguments — self-documenting call sites
showDialog(
    title = "Confirm",
    message = "Are you sure?",
    positiveText = "Yes",
    negativeText = "Cancel",
    cancelable = false
)

// Named args can be reordered
createUser(role = "admin", name = "Rahul", age = 25)

// Java equivalent would need 4 overloads:
// createUser(String name)
// createUser(String name, int age)
// createUser(String name, int age, String role)
// createUser(String name, int age, String role, boolean active)

// Builder pattern replacement in Kotlin
data class Config(
    val baseUrl: String,
    val timeout: Int = 5000,
    val retries: Int = 3,
    val debug: Boolean = false
)

Default arguments eliminate overload pyramids. A single Kotlin function with four parameters — two of which have defaults — replaces the three Java overloads you would otherwise need. The call site is cleaner and the logic lives in one place.

Named arguments make call sites self-documenting and remove the need to memorize parameter order. showDialog(title = "Confirm", message = "Are you sure?", cancelable = false) reads like documentation; showDialog("Confirm", "Are you sure?", true, false) requires you to look up the signature.

Named arguments can be reordered freely — createUser(role = "admin", name = "Rahul") is valid regardless of the function's declaration order. This is impossible with positional arguments and removes a whole class of subtle ordering bugs.

Replaces the Builder pattern in most Kotlin cases. A data class with default values for optional fields — Config(baseUrl = "https://api.example.com", timeout = 3000) — is more concise and type-safe than a Java Builder, with no extra class or fluent-chaining boilerplate.

@JvmOverloads is required for Java interop when default arguments are involved. Without it, Java callers must provide every argument explicitly since Java has no concept of default parameters.

💡 Interview Tip

Named arguments solve the 'stringly-typed' problem: createUser(name = "Alice", age = 25) is self-documenting. Default arguments replace builder patterns and overloaded constructors. The @JvmOverloads annotation is required to expose these defaults to Java callers. A great interview point: named + default arguments make your API backward-compatible when adding new optional parameters.

Mark it done or save it in the question bank · Back to top

Hard

31. What is Kotlin's type inference? How does it work with local variables and generics?

Kotlin's compiler infers types at compile time, eliminating redundant type declarations while maintaining full static type safety. Understanding its limits is important for senior developers.

// Local variable inference
val name = "Rahul"         // inferred: String
val age  = 25             // inferred: Int
val list = listOf(1,2,3) // inferred: List<Int>
val map  = mapOf("a" to 1) // inferred: Map<String, Int>

// Function return type inference
fun double(x: Int) = x * 2          // inferred return: Int
fun greet(name: String) = "Hi $name" // inferred return: String

// Generic type inference
fun <T> identity(x: T) = x
val s = identity("hello")  // T inferred as String
val i = identity(42)       // T inferred as Int

// Smart cast — type narrowed after check
fun process(x: Any) {
    if (x is String) {
        println(x.length)  // x smart-cast to String
    }
    val len = (x as? String)?.length ?: 0
}

// Where inference doesn't work — must be explicit
val empty = emptyList<String>()  // must specify type
val result: Number = 42           // explicit wider type

// Return type always explicit for public API functions
fun getUser(): User = fetchFromDb()  // explicit for clarity

Type inference happens at compile time — the inferred type is locked in at build time with full static type safety. There is zero runtime cost. val name = "Rahul" is still typed as String; you just don't have to write it.

Inference works for local variables, function return types, and generic type parameters. val list = listOf(1,2,3) infers List<Int>. fun double(x: Int) = x * 2 infers return type Int. val s = identity("hello") infers T as String — the compiler resolves generic parameters from the argument.

Smart casts are a form of local type inference — after an is check, the compiler narrows the variable's type in that scope. No explicit cast needed: if (x is String) println(x.length) just works. K2 extends this to mutable var properties in some cases that K1 couldn't handle.

Where inference requires explicit annotation — empty collections (emptyList<String>() must specify the type), when you need a wider type than the compiler would infer (val result: Number = 42), and on public API function return types for clarity and stable API contracts.

Public API functions should always declare return types explicitly even when inference would work. This prevents accidental API changes when the implementation changes, makes documentation and IDE hints clearer, and is an enforced style in most Kotlin codebases.

💡 Interview Tip

Type inference keeps code concise without sacrificing safety — the type is still checked at compile time. The key limitation: inference doesn't work across function boundaries for generic return types. Mention the _ wildcard for type arguments in Kotlin 1.9+ and how Kotlin 2.0's improved inference reduces the need for explicit annotations in complex generic expressions.

Mark it done or save it in the question bank · Back to top

MediumMost asked

32. What are Kotlin's collection transformation functions? Explain map, filter, reduce, fold, groupBy, partition.

Kotlin's collection API is vastly richer than Java's. Mastering these functions eliminates most imperative for-loop code and is heavily tested in interviews.

val users = listOf(
    User("Alice", 30, "admin"),
    User("Bob", 25, "user"),
    User("Carol", 35, "admin")
)

// map — transform each element
val names = users.map { it.name }  // [Alice, Bob, Carol]

// filter — keep matching elements
val admins = users.filter { it.role == "admin" }

// reduce — combine all into one (no initial value)
val totalAge = users.map { it.age }.reduce { sum, age -> sum + age }  // 90

// fold — like reduce but with initial value
val summary = users.fold("") { acc, user -> "$acc ${user.name}" }

// groupBy — group into Map by key
val byRole = users.groupBy { it.role }
// {admin=[Alice, Carol], user=[Bob]}

// partition — split into Pair(matching, notMatching)
val (admins2, others) = users.partition { it.role == "admin" }

// associate — build a Map
val nameToAge = users.associate { it.name to it.age }
// {Alice=30, Bob=25, Carol=35}

// flatMap — map then flatten
val allChars = users.flatMap { it.name.toList() }

// any / all / none / count
users.any { it.age > 30 }    // true
users.all { it.age > 20 }    // true
users.none { it.age > 50 }   // true
users.count { it.role == "admin" }  // 2

map and filter are the workhorses — map transforms every element one-to-one, filter keeps only elements matching a predicate. Combined they eliminate most imperative for-loop + mutableList code: users.filter { it.active }.map { it.name }.

reduce vs fold is a key distinction. reduce combines elements with no initial value, so it throws on an empty list. fold takes an explicit initial value making it safe on empty collections and more flexible — the accumulator can be a different type than the elements.

groupBy returns a Map<K, List<V>> — each key maps to a list of all elements that produced it. This is the idiomatic way to bucket elements: users.groupBy { it.role } gives {admin=[Alice, Carol], user=[Bob]}.

partition splits a collection into two lists in a single pass — a Pair(matching, notMatching). It is more efficient than calling filter twice and makes the two groups explicit at the call site through destructuring.

associate builds a Map from a collection where each element produces a key-value Pair. Unlike groupBy it keeps only the last value for duplicate keys. For associating by a key field specifically, associateBy { it.id } is cleaner and signals the intent more clearly.

💡 Interview Tip

The two most important distinctions: reduce throws on empty collections (use fold with an identity element for safety), and groupBy returns a Map<K, List<V>> while associateBy returns Map<K, V> (last value wins on duplicate keys). For interview demos: items.groupBy { it.category }.mapValues { it.value.sumOf { item -> item.price } } is a great concise aggregation example.

Mark it done or save it in the question bank · Back to top

Hard

33. What is Kotlin's Result type and how does it differ from exceptions?

Result<T> is Kotlin's functional error handling type — it wraps either a success value or a failure exception, making error handling explicit and composable without try-catch everywhere.

// Result — success or failure wrapped in a type
fun divide(a: Int, b: Int): Result<Int> = runCatching {
    require(b != 0) { "Division by zero" }
    a / b
}

// Handle result
divide(10, 2)
    .onSuccess { println("Result: $it") }   // 5
    .onFailure { println("Error: ${it.message}") }

// Get value or default
val value = divide(10, 0).getOrDefault(0)
val value2 = divide(10, 0).getOrElse { -1 }
val value3 = divide(10, 2).getOrThrow()  // throws if failure

// Chain operations with map
val result = divide(10, 2)
    .map { it * 2 }              // 10
    .map { it.toString() }      // "10"

// ViewModel pattern with Result
viewModelScope.launch {
    _state.value = UiState.Loading
    runCatching { api.fetchUser() }
        .onSuccess { _state.value = UiState.Success(it) }
        .onFailure { _state.value = UiState.Error(it.message!!) }
}

// Compared to try-catch — cleaner, chainable, explicit

Result<T> encapsulates either a success value or a failure exception in a single type, making the possibility of failure explicit in function signatures. suspend fun getUser(id: String): Result<User> tells callers to handle both outcomes — no surprise exceptions.

runCatching wraps any block of code in a Result, catching all exceptions: runCatching { riskyOp() }. This is the most convenient way to convert exception-throwing code into Result-returning code at a single boundary point.

onSuccess / onFailure handle both cases functionally and both return the original Result, so they chain cleanly: runCatching { fetch() }.onSuccess { updateState(it) }.onFailure { logError(it) }. Neither transforms the value — use map for that.

map transforms the success value while passing failure through unchanged — result.map { it * 2 } returns a new Result with the transformed value on success and the original failure on failure. This lets you chain transformations without unpacking the Result at each step.

Best used at boundaries — repository layer, network calls, and parsing — where errors are expected and callers should handle them explicitly. Avoid returning Result deep inside pure business logic; keep it at the edges where external calls can fail.

💡 Interview Tip

The practical Android use case: use Result in Repository return types — suspend fun getUser(id: String): Result<User>. The ViewModel calls .onSuccess { updateState(it) }.onFailure { handleError(it) }. This separates error handling from the happy path without try-catch everywhere. Contrast with exceptions: Result makes failure explicit in the type signature.

Mark it done or save it in the question bank · Back to top

MediumMost asked

34. What are Kotlin annotations? How do you create custom annotations?

Annotations add metadata to code that can be read at compile time or runtime. They're heavily used in Android (Room, Retrofit, Hilt, Compose) — understanding them helps you write your own libraries.

// Built-in Kotlin annotations
@Deprecated("Use newFunction() instead", ReplaceWith("newFunction()"))
fun oldFunction() { }

@Suppress("UNCHECKED_CAST")
fun cast(x: Any) = x as String

@JvmStatic @JvmOverloads  // Java interop annotations

// Custom annotation
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresAuth(
    val role: String = "user"
)

@RequiresAuth(role = "admin")
fun deleteUser(id: String) { }

// Read annotation at runtime via reflection
val annotation = ::deleteUser.findAnnotation<RequiresAuth>()
println(annotation?.role)  // "admin"

// Android annotations you use daily
@Composable   // Compose — marks composable functions
@HiltViewModel // Hilt — inject ViewModel
@Entity       // Room — marks database table
@GET("/users")  // Retrofit — HTTP GET
@Parcelize    // generates Parcelable

@Target restricts where an annotation may be applied — AnnotationTarget.FUNCTION limits it to functions, PROPERTY to properties, CLASS to classes, and so on. Without @Target an annotation can be placed anywhere, which is usually too permissive.

@Retention controls how long the annotation survives. SOURCE means it is discarded after compilation (good for lint and IDE hints). BINARY stores it in bytecode but not readable at runtime. RUNTIME keeps it accessible via reflection — required if you need to inspect annotations at runtime. Forgetting RUNTIME is the most common annotation bug.

Most Android framework annotations use compile-time processing — Room, Hilt, Retrofit, and Parcelize all generate code at build time via annotation processors, never at runtime. This is what makes them fast and R8-safe.

KSP (Kotlin Symbol Processing) is the modern replacement for kapt. It processes Kotlin symbols directly instead of compiling to Java stubs first, making annotation processing significantly faster. Room, Hilt, and Moshi all support KSP. Prefer KSP over kapt in new projects.

💡 Interview Tip

Annotations are compile-time metadata. In Android, custom annotations power lint rules, custom code generation (Hilt, Room), and compile-time validation. The most common mistake: forgetting @Retention(AnnotationRetention.RUNTIME) when the annotation needs to be inspectable at runtime via reflection. For source-level annotations (code generation only), SOURCE retention is more efficient.

Mark it done or save it in the question bank · Back to top

Hard

35. What is Kotlin Reflection? When would you use it in Android?

Reflection allows inspecting and manipulating code structure at runtime. It's powerful but slow — use sparingly and prefer compile-time alternatives like KSP when possible.

// KClass — Kotlin's runtime class representation
val kClass = String::class          // KClass<String>
val jClass = String::class.java     // Class<String> for Java APIs

// Inspect class members
data class User(val name: String, val age: Int)

User::class.memberProperties.forEach { prop ->
    println("${prop.name}: ${prop.returnType}")
}
// name: String, age: Int

// Access property value by name
val user = User("Rahul", 25)
val nameProp = User::class.memberProperties
    .first { it.name == "name" }
println(nameProp.get(user))  // "Rahul"

// Callable references
val nameRef: KProperty1<User, String> = User::name
val funcRef: (String) -> Int = String::length

// createInstance — instantiate by class
val instance = User::class.primaryConstructor!!
    .call("Bob", 30)

// ⚠️ Reflection is SLOW — avoid in hot paths
// Use KSP/kapt for compile-time code generation instead
// Room, Hilt, Moshi use KSP — zero runtime reflection cost

KClass is Kotlin's runtime class representation, obtained with the ::class syntax. String::class gives KClass<String>; String::class.java bridges to Java's Class<String> for Java APIs. From KClass you can inspect member properties, constructors, annotations, and the class hierarchy.

Callable references (::) are lightweight and do not incur reflection overhead — User::name is a typed property reference resolved at compile time, not a runtime lookup. Prefer these over full reflection for common tasks like sorting (sortedBy(User::name)) or mapping (map(User::name)).

Reflection is slow and should be avoided in hot paths — RecyclerView adapters, custom Views, any code called repeatedly on the main thread. A single reflection call is orders of magnitude slower than a direct method call. If you reach for reflection in a tight loop, reconsider the design.

KSP generates code at compile time, giving you all the power of inspecting class structure with none of the runtime cost. Room generates DAO implementations, Hilt generates injectors, Moshi generates adapters — all from annotations, all resolved at build time. This is the right tool for most "meta-programming" needs in Android.

💡 Interview Tip

Reflection in Android is expensive and ProGuard/R8 can strip reflected classes without proper keep rules. Use it sparingly — prefer compile-time code generation (kapt/ksp) for most cases. The most common legitimate use: serialization libraries inspecting property names/types, and testing utilities that need to access private members. Always add keep rules for any class accessed via reflection in release builds.

Mark it done or save it in the question bank · Back to top

MediumMost asked

36. What are infix functions in Kotlin? Give real-world examples.

Infix functions allow calling a function without a dot or parentheses, making code read more like natural language. They're used throughout Kotlin's standard library and test frameworks.

// infix function — single parameter, called without dot/parentheses
infix fun Int.times(str: String) = str.repeat(this)
println(3 times "hello")  // "hellohellohello"

// Standard library infix functions
val pair = "key" to "value"    // to is infix!
val map = mapOf("a" to 1, "b" to 2)
val range = 1 until 10         // until is infix
val step = 1..10 step 2        // step is infix
val inRange = 5 in 1..10      // in is infix operator

// Testing with infix (KoTest, MockK)
// KoTest assertions
5 shouldBe 5
"hello" shouldContain "ell"
users shouldHaveSize 3

// Custom DSL-style infix
infix fun String.shouldEqual(expected: String) {
    assertEquals(expected, this)
}
"Rahul".uppercase() shouldEqual "RAHUL"

// Rules for infix functions:
// Must be member or extension function
// Must have exactly ONE parameter
// Parameter cannot have default value or vararg

Infix functions must be member or extension functions with exactly one parameter — no vararg, no default value. When those conditions are met, the infix modifier lets callers drop the dot and parentheses for a more natural reading: 3 times "hello" instead of 3.times("hello").

Standard library infix functions you use daily include to (creates Pairs), until and downTo (range creation), step (progression stride), and bitwise operations and, or, xor, shl, shr. The to function alone is used in every mapOf call.

Test frameworks rely heavily on infix for readable assertions. KoTest's shouldBe, shouldContain, shouldHaveSize and MockK's every { mock.method() } returns value all use infix to read like plain English rather than method chaining.

Use infix sparingly — only when the operator reading is genuinely clearer than dot notation. user.hasRole("admin") is immediately understandable; user hasRole "admin" is readable but requires knowing the infix name. Overusing infix creates code that reads like a novel but confuses people unfamiliar with the DSL.

💡 Interview Tip

Infix functions read like natural language, making DSLs and test assertions expressive: user should haveRole "admin", 1 to 2, a until b. The rule: use infix only when the function is called frequently AND the operator syntax makes intent clearer than dot notation. Don't overuse — user.haveRole("admin") is clearer when the infix name isn't immediately obvious.

Mark it done or save it in the question bank · Back to top

MediumMost asked

37. What are varargs in Kotlin? How does the spread operator work?

vararg allows passing a variable number of arguments of the same type. The spread operator (*) is used to pass an array where vararg is expected.

// vararg — variable number of arguments
fun sum(vararg numbers: Int): Int = numbers.sum()

sum(1, 2, 3)        // 6
sum(1, 2, 3, 4, 5)  // 15
sum()               // 0 — empty is valid

// vararg is treated as Array inside function
fun printAll(vararg items: String) {
    items.forEach { println(it) }  // items is Array<String>
}

// Spread operator * — pass array as vararg
val nums = intArrayOf(1, 2, 3)
sum(*nums)  // spread array into vararg

val strs = arrayOf("a", "b")
printAll(*strs)  // spread
printAll("x", *strs, "y")  // mix spread with individual args

// Common use: listOf, mapOf, println
val list = listOf("a", "b", "c")  // vararg under the hood

// Only ONE vararg per function, should be last parameter
fun log(tag: String, vararg messages: String) {
    messages.forEach { Log.d(tag, it) }
}

vararg accepts zero or more arguments of the same type at the call site. Inside the function the parameter is typed as an Array, so all array operations (forEach, size, indexing) work on it directly. Passing no arguments is valid — it gives you an empty array.

The spread operator (*) unpacks an existing array into individual vararg arguments: sum(*nums) passes each element of nums as a separate argument. Without it, you'd be passing the array as a single element, which the compiler would reject.

Mixing spread with individual arguments is allowed: printAll("x", *strs, "y") passes "x", then all elements of strs, then "y" as a flat sequence of vararg arguments. This gives a lot of flexibility when constructing argument lists dynamically.

One vararg per function, ideally last — placing a vararg in the middle forces named arguments for anything after it, which is awkward for callers. The standard library follows this pattern: println(vararg parts: Any) keeps vararg at the end. There is also a performance consideration — each call site allocates a new array, so avoid vararg in tight hot paths.

💡 Interview Tip

The spread operator (*array) is needed to pass an Array to a vararg parameter. Mention the performance concern: in Kotlin, passing a vararg creates a new array at each call site. For performance-critical code called in tight loops, prefer an overload that accepts a List or Array directly to avoid the extra allocation.

Mark it done or save it in the question bank · Back to top

Hard

38. What is Kotlin's Nothing type? How is it used in practice?

Nothing is Kotlin's bottom type — a subtype of every type. It represents computations that never complete normally (throws or loops forever), enabling the type system to remain sound.

// Nothing — function never returns normally
fun fail(msg: String): Nothing = throw IllegalStateException(msg)
fun infiniteLoop(): Nothing { while (true) {} }

// Nothing is subtype of every type — so this compiles:
val name: String = user?.name ?: fail("Name required")
// fail() returns Nothing which is a subtype of String

val result: Int = if (condition) 42 else fail("bad")
// if branch is Int, else is Nothing — result is Int

// throw is also of type Nothing
val x: String = throw Exception()  // compiles! throw is Nothing

// Nothing? — the nullable Nothing type
// Only possible value is null
val nothing: Nothing? = null

// Used in sealed classes for impossible branches
sealed class Result<out T> {
    object Loading : Result<Nothing>()    // no data type needed
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val e: Exception) : Result<Nothing>()
}

Nothing is the bottom type — a subtype of every type with no possible instances. A function declared to return Nothing communicates that it will never complete normally: it always throws an exception or runs forever. The compiler marks all code after a Nothing-returning call as unreachable.

throw has type Nothing, which is why it can appear in an Elvis operator or any branch of a when expression without breaking the expression's overall type. val name: String = user?.name ?: throw Exception() type-checks because Nothing is a subtype of String.

Nothing? has exactly one value: null. It is the type the compiler infers for null literals when no other type is available. This is why null == null evaluates correctly and null can be assigned to any nullable variable — Nothing? is a subtype of every nullable type.

In sealed classes, Nothing is used for variants that carry no data of the parameterized type. Result.Loading and Result.Error both extend Result<Nothing> because they don't produce a T value — using Nothing here rather than a wildcard or Any preserves type safety and allows covariant usage.

💡 Interview Tip

Nothing is Kotlin's bottom type — a subtype of every type with no instances. Functions that always throw return Nothing: fun fail(msg: String): Nothing = throw IllegalStateException(msg). The compiler uses this for smart casts and exhaustiveness: after a Nothing-returning call, all subsequent code is marked unreachable. Nothing? has exactly one value: null.

Mark it done or save it in the question bank · Back to top

MediumMost asked

39. What is lazy initialization vs eager initialization in Kotlin? When to use each?

Kotlin provides multiple patterns for controlling when objects are created — choosing correctly impacts startup time, memory usage, and thread safety.

// EAGER — created immediately at declaration
val config = AppConfig()           // created NOW
val list = mutableListOf<String>() // created NOW

// LAZY — created on first access
val heavyObject by lazy {
    println("Creating...")
    HeavyDatabase()
}
// HeavyDatabase() not called yet
heavyObject.query()  // NOW it's created (once only)
heavyObject.query()  // uses cached instance

// lazy is thread-safe by default (SYNCHRONIZED mode)
val safeObj by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { heavyInit() }

// NONE mode — single thread only, no lock overhead
val fastObj by lazy(LazyThreadSafetyMode.NONE) { init() }

// lateinit — deferred initialization for non-null vars
lateinit var binding: ActivityMainBinding
// binding used BEFORE initialization → UninitializedPropertyAccessException
if (::binding.isInitialized) { binding.doSomething() }

// Android patterns:
// by viewModels() — lazy ViewModel access
// by lazy { Room.databaseBuilder(...).build() } — singleton DB
// lateinit var — DI-injected fields, view binding

Eager initialization is simple and predictable — use it when the object is always needed and cheap to create. val config = AppConfig() is created immediately when the surrounding class or scope is initialized. No lazy overhead, no thread-safety concerns.

by lazy defers creation to the first access and caches the result. By default it uses SYNCHRONIZED mode — double-checked locking that is safe across threads. Use it for expensive objects that may not be needed at all (like a database instance that's only accessed after a user action).

LazyThreadSafetyMode.NONE skips synchronization entirely for faster initialization. Use it only when you can guarantee the property will never be accessed from multiple threads simultaneously — such as a lazy value inside a single-threaded ViewModel that is always touched on the main thread.

lateinit is for non-null var properties that must be set before use but cannot be initialized at construction time — DI-injected fields, view binding in Fragments, and test fixtures. Accessing a lateinit property before it's set throws UninitializedPropertyAccessException with a clear message.

::binding.isInitialized lets you check safely before accessing a lateinit property. Use this in lifecycle callbacks (onDestroyView) where binding might have been released, or in tests to assert that injection ran before the test code executes.

💡 Interview Tip

Decision rule: eager initialization when the value is always needed and cheap to create; lazy when it may not be needed OR is expensive. by lazy uses double-checked locking by default (thread-safe). For ViewModels, use by lazy for use-case instances that require constructor parameters. Watch out for LazyThreadSafetyMode.NONE — it's faster but not safe for shared access from multiple coroutines.

Mark it done or save it in the question bank · Back to top

Hard

40. What are Kotlin's open and abstract classes? How does Kotlin differ from Java in class design?

Kotlin classes are final by default — the opposite of Java. This is a deliberate design choice promoting composition over inheritance and preventing fragile base class problems.

// Kotlin classes are FINAL by default
class Animal  // cannot be subclassed!

// open — allows subclassing and overriding
open class Vehicle(val brand: String) {
    open fun start() { println("Starting $brand") }
    fun stop() { println("Stopping") }  // NOT open — cannot override
}

class Car(brand: String) : Vehicle(brand) {
    override fun start() { println("Vroom!") }  // ✅
    // override fun stop() { }  ❌ stop() is not open
}

// abstract — must be subclassed, cannot be instantiated
abstract class Shape {
    abstract fun area(): Double    // must implement
    open fun describe() = "Shape"  // can override
    fun print() = println(area())     // cannot override
}

class Circle(val r: Double) : Shape() {
    override fun area() = Math.PI * r * r
}

// interface — multiple implementation, default methods
interface Drawable {
    fun draw()                   // abstract
    fun hide() { println("hidden") }  // default implementation
}
// class can implement multiple interfaces but extend only one class

All Kotlin classes are final by default — you cannot subclass them unless they are explicitly marked open or abstract. This is the opposite of Java and is intentional: it prevents the fragile base class problem where an unplanned subclass breaks because the superclass changed an implementation detail.

open opts a class and its methods into extensibility. Only methods marked open can be overridden — a method in an open class is still final unless individually marked open. This granularity forces you to be deliberate about what is part of the public extension contract.

abstract means the class cannot be instantiated and at least some members have no implementation. All abstract members must be overridden by concrete subclasses. Abstract classes are automatically open — you don't need both keywords.

override is required explicitly in Kotlin, unlike Java where you could accidentally override a method with the same signature. The compiler enforces the keyword, preventing silent behavioral changes when a superclass adds a new method that clashes with a subclass method.

Prefer interfaces over abstract classes — a class can implement multiple interfaces but can only extend one class. Interfaces with default method bodies cover most use cases previously served by abstract classes, while keeping the type hierarchy more flexible.

💡 Interview Tip

Kotlin classes are final by default — use open to allow subclassing. This is intentional: it prevents the fragile base class problem. The trade-off: 'All-open' plugin (used by Spring/Mockito) or @OpenForTesting patterns are needed for mocking. In Android, prefer composition over inheritance; use abstract for template method patterns in base ViewModels or Fragments.

Mark it done or save it in the question bank · Back to top

MediumMost asked

41. What is the difference between map and flatMap in Kotlin collections?

map transforms each element to one output. flatMap transforms each element to a collection, then flattens all collections into a single list — a very commonly tested distinction.

// map — one input, one output
val words = listOf("hello", "world")
val lengths = words.map { it.length }
// [5, 5] — one Int per String

// map of collections — nested result
val chars = words.map { it.toList() }
// [[h,e,l,l,o], [w,o,r,l,d]] — List<List<Char>>

// flatMap — one input, many outputs, then flatten
val flat = words.flatMap { it.toList() }
// [h,e,l,l,o,w,o,r,l,d] — List<Char> flattened

// Real-world: user orders
data class User(val name: String, val orders: List<String>)

val users = listOf(
    User("Alice", listOf("shoes", "bag")),
    User("Bob", listOf("phone"))
)

// map — nested lists
val allOrdersNested = users.map { it.orders }
// [[shoes, bag], [phone]]

// flatMap — flat list of all orders
val allOrders = users.flatMap { it.orders }
// [shoes, bag, phone]

// flatten() — flatten already-nested collection
val nested = listOf(listOf(1,2), listOf(3,4))
val flat2 = nested.flatten()  // [1,2,3,4]

map is a 1-to-1 transformation — each input element produces exactly one output element. The result collection is always the same size as the input. When you map words.map { it.toList() }, you get a List<List<Char>> — one inner list per word.

flatMap is a 1-to-many transformation followed by flattening. Each element maps to a collection, then all those collections are concatenated into a single flat result. words.flatMap { it.toList() } gives you all characters from all words in one flat List<Char>.

flatMap = map + flatten in a single pass. Calling map { }.flatten() produces the same result but flatMap is more efficient and expressive. Whenever you catch yourself writing map followed by flatten, reach for flatMap.

In Kotlin Flow, the flatMap family handles async one-to-many transformations: flatMapLatest cancels the previous inner flow when a new value arrives (good for search), flatMapConcat processes them sequentially, and flatMapMerge processes them concurrently. These are the async counterparts to the collection operation.

💡 Interview Tip

flatMap is map + flatten: it transforms each element to a collection and flattens the result into a single list. The canonical example: users.flatMap { it.orders } gives all orders from all users as a flat list, vs map which would give List<List<Order>>. In coroutines, flatMapMerge/flatMapLatest on Flow are the async equivalents — mention these for bonus points.

Mark it done or save it in the question bank · Back to top

MediumMost asked

42. How does Kotlin handle SAM (Single Abstract Method) conversions?

SAM conversion allows using a lambda wherever a Java functional interface (an interface with one abstract method) is expected, eliminating anonymous class boilerplate when working with Java APIs.

// Java functional interface (SAM)
// interface Runnable { void run(); }

// Without SAM — verbose anonymous class
val r = object : Runnable {
    override fun run() { println("Running") }
}

// With SAM conversion — clean lambda
val r2 = Runnable { println("Running") }
Thread(r2).start()

// Most common SAM conversions in Android
val handler = Handler(Looper.getMainLooper())
handler.post { updateUi() }          // Runnable SAM

view.setOnClickListener { handleClick() }  // View.OnClickListener SAM

executor.submit { doWork() }          // Callable/Runnable SAM

// Kotlin interfaces — NOT SAM by default
interface KotlinCallback { fun onResult(result: String) }
// KotlinCallback { result -> ... }  ❌ doesn't work automatically

// fun interface — Kotlin SAM interface
fun interface KotlinCallback {
    fun onResult(result: String)
}
val cb = KotlinCallback { result -> println(result) }  // ✅ now works

SAM conversion lets you pass a lambda wherever a Java interface with a single abstract method is expected. The compiler automatically wraps the lambda in an anonymous implementation of that interface. This works for all Java functional interfaces without any annotation.

Kotlin interfaces are not SAM-convertible by default. If you define your own interface with one abstract method, callers cannot pass a lambda — they must write an object expression. This is a deliberate choice to prevent surprising implicit conversions in pure Kotlin code.

fun interface (introduced in Kotlin 1.4) explicitly opts a Kotlin interface into SAM conversion. Mark it once — fun interface OnClick { fun onClick(view: View) } — and callers can pass lambdas directly, the same as Java SAM interfaces.

Common SAM interfaces in Android — Runnable, Callable, View.OnClickListener, Comparator, and TextWatcher's individual callbacks — are all Java interfaces, so SAM conversion works automatically. This is why view.setOnClickListener { } compiles without any fun interface annotation on Android's SDK.

💡 Interview Tip

SAM conversions let you pass a lambda wherever a single-abstract-method interface is expected. In Kotlin, this works automatically for Java interfaces (Runnable, Callable). For Kotlin interfaces, use fun interface to opt in: fun interface OnClick { fun onClick(view: View) }. This enables button.setOnClick { ... } lambda syntax for your own interfaces.

Mark it done or save it in the question bank · Back to top

Hard

43. What are Kotlin coroutine basics — suspend functions and how does the compiler transform them?

Understanding how suspend functions work under the hood — specifically the continuation-passing style transformation — is a key differentiator for senior Android developers.

// suspend function — can pause execution without blocking thread
suspend fun fetchUser(): User {
    return withContext(Dispatchers.IO) { api.getUser() }
}

// Can only be called from coroutine or another suspend function
viewModelScope.launch {
    val user = fetchUser()  // suspend point — thread released
    updateUi(user)
}

// How compiler transforms suspend functions:
// Kotlin compiler transforms to CPS (Continuation-Passing Style)
// suspend fun fetchUser(): User
// becomes internally:
// fun fetchUser(continuation: Continuation<User>): Any

// Continuation — the "rest of the computation" after suspension
// Like a callback, but written as sequential code

// State machine — compiler generates states for each suspension point
// suspend fun example() {
//   val a = fetch1()    // State 0: call fetch1, suspend
//   val b = fetch2(a)   // State 1: call fetch2, suspend
//   return combine(a,b) // State 2: return result
// }

// suspendCoroutine — create custom suspend functions
suspend fun awaitCallback(): String = suspendCoroutine { cont ->
    asyncApi.fetch(
        onSuccess = { cont.resume(it) },
        onError = { cont.resumeWithException(it) }
    )
}

suspend marks a function as suspendable — it can pause execution and release its thread without blocking. The compiler transforms it into a state machine where each suspension point (each call to another suspend function) becomes a separate state that can resume later.

Under the hood, every suspend function takes a Continuation parameter. suspend fun fetchUser(): User becomes fun fetchUser(continuation: Continuation<User>): Any at the JVM level. The Continuation is essentially the "rest of the computation" — a callback that the coroutine runtime invokes when the suspended operation completes.

The state machine generated by the compiler stores local variables and the current state index in the Continuation object. When the coroutine resumes, it reads the saved state and jumps to the right point in the function. This is why multiple sequential await calls in one function work without nested callbacks.

suspendCoroutine and suspendCancellableCoroutine are how you bridge old callback-based APIs to suspend functions. You get a Continuation, pass resume/resumeWithException to the callback, and the coroutine stays suspended until one of those is called. This is the standard way to wrap Retrofit callback APIs, camera APIs, and any legacy async code.

Zero JVM magic — suspend functions are ordinary JVM methods. The JVM knows nothing about coroutines. All the threading logic lives in the coroutine runtime library and the generated state machine. This is why coroutines have minimal overhead and integrate seamlessly with Java libraries.

💡 Interview Tip

Interviewers want you to mention the state machine transformation: the compiler rewrites suspend functions into a Continuation-passing style state machine. Each suspension point becomes a state. This is why suspending functions have no runtime overhead when they don't actually suspend — they return immediately without blocking the thread. The Continuation callback resumes the state machine when the suspended operation completes.

Mark it done or save it in the question bank · Back to top

MediumMost asked

44. What are Kotlin's range and progression operators?

Ranges and progressions are first-class in Kotlin, enabling clean iteration, validation, and conditional checks that would require verbose code in Java.

// IntRange — .. operator
val range = 1..10       // 1 to 10 inclusive
val until = 1 until 10 // 1 to 9 (excludes 10)
val down  = 10 downTo 1 // 10,9,8,...,1

// step — custom step size
1..10 step 2    // 1,3,5,7,9
10 downTo 0 step 3 // 10,7,4,1

// Iteration
for (i in 1..5) print(i)        // 12345
for (i in 5 downTo 1) print(i) // 54321
for (i in 0 until list.size) { } // index iteration
for ((index, value) in list.withIndex()) { } // preferred

// Range checks — in operator
val score = 85
if (score in 80..100) println("A grade")
if (score !in 0..50) println("Pass")

// String/Char ranges
val letters = 'a'..'z'
if ('e' in letters) println("vowel check")

// when with ranges
val grade = when (score) {
    in 90..100 -> "A"
    in 80..89  -> "B"
    in 70..79  -> "C"
    else        -> "F"
}

.. creates an inclusive range — 1..10 includes both 1 and 10. until creates an exclusive end — 0 until list.size excludes list.size, making it the idiomatic way to iterate indices without risk of off-by-one errors.

downTo creates a descending progression — 10 downTo 1 iterates 10, 9, 8 ... 1. Combined with step you get full control: 10 downTo 0 step 3 gives 10, 7, 4, 1. The step function also works on ascending ranges: 1..10 step 2 gives 1, 3, 5, 7, 9.

The in operator for range checks compiles to a simple numeric comparison — x in 1..100 compiles to x >= 1 && x <= 100. No Range object is iterated; no intermediate list is created. This makes in-range checks in hot code (tight loops, RecyclerView item decorations) completely efficient.

Ranges work for Int, Long, Char, Double, and any Comparable type through custom rangeTo. String and Char ranges are commonly used for alphabet checks: 'a'..'z'. Combined with when expressions, ranges replace Java's verbose series of if-else if comparisons cleanly.

💡 Interview Tip

Ranges are inclusive by default (1..10 includes 10). Use until for exclusive end (0 until list.size = indices). downTo for reversed iteration. step for custom increments. Ranges implement ClosedRange and work with in checks: x in 1..100 compiles to an efficient x >= 1 && x <= 100 — no object creation in bytecode.

Mark it done or save it in the question bank · Back to top

Hard

45. What is Kotlin's context receivers feature? How does it improve code organization?

Context receivers (experimental in Kotlin 1.6+, stable in 2.0) allow functions to declare multiple receivers — solving the "multiple context" problem elegantly. This is one of Kotlin's most exciting 2024-25 features.

// Problem: function needs multiple contexts
// Ugly way — pass everything as parameters
fun showMessage(context: Context, scope: CoroutineScope, msg: String) {
    scope.launch { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() }
}

// Context receivers — declare multiple implicit contexts
context(Context, CoroutineScope)
fun showMessage(msg: String) {
    launch {  // CoroutineScope available
        Toast.makeText(this@Context, msg, Toast.LENGTH_SHORT).show()
    }
}

// Call site — contexts provided automatically
with(applicationContext) {   // Context
    viewModelScope.launch {  // CoroutineScope
        showMessage("Hello!")   // both contexts satisfied
    }
}

// Enable in build.gradle.kts
// kotlinOptions { freeCompilerArgs += "-Xcontext-receivers" }

// Use case — transaction DSL
context(Database.Transaction)
fun saveUser(user: User) {
    insert(user)  // Transaction methods available
    updateIndex(user.id)
}
// Can ONLY be called inside a transaction — compile-time safety!

Context receivers solve the multiple implicit context problem. When a function legitimately needs access to a Context, a CoroutineScope, and a Logger simultaneously, the alternatives are ugly: pass all three as parameters, or use global state. Context receivers let you declare them all as implicit, provided at the call site.

Compile-time safety is the key benefit. A function declared with context(Database.Transaction) can only be called from a scope where a Transaction is in scope. The compiler enforces this, making it impossible to accidentally call a transactional function outside a transaction — no runtime checks needed.

Stable in Kotlin 2.0 under the context() syntax. For earlier versions (Kotlin 1.6+) the -Xcontext-receivers compiler flag enables the experimental version. The stable API is slightly different from the experimental one, so check migration notes if upgrading.

Context receivers vs alternatives — they are cleaner than passing many parameters through long call chains, safer than thread-locals or ambient globals, and more explicit than dependency injection for truly function-scoped resources like database transactions and logging contexts.

💡 Interview Tip

Context receivers (experimental) allow a function to require multiple implicit receivers: context(Logger, Database) fun processUser(user: User). This solves the problem of passing infrastructure dependencies (logger, DB connection) through long call chains. The interviewer may ask about alternatives like extension functions on a context interface or dependency injection — mention that context receivers are still experimental and should be used with caution in production.

Mark it done or save it in the question bank · Back to top

MediumMost asked

46. What is the difference between Kotlin's List, Set, and Map? When do you use each?

Kotlin's three main collection types each have distinct characteristics around ordering, uniqueness, and key-value access. Choosing the right one impacts correctness and performance.

// List — ordered, allows duplicates
val list = listOf("a", "b", "a")  // [a, b, a] — keeps order, keeps dups
list[0]          // "a" — index access
list.indexOf("a")  // 0 — finds first

// Set — unordered, NO duplicates
val set = setOf("a", "b", "a")   // {a, b} — duplicate removed
"a" in set      // O(1) lookup — much faster than list.contains()
// set[0]  ❌ no index access

// LinkedHashSet — insertion order preserved
val linked = linkedSetOf("c", "a", "b")  // [c, a, b] — ordered, unique

// Map — key-value pairs, unique keys
val map = mapOf("one" to 1, "two" to 2)
map["one"]         // 1
map.getOrDefault("three", 0)  // 0
map.getOrPut("four") { 4 }  // insert if absent

// Mutable versions
val mList = mutableListOf("a"); mList.add("b")
val mSet  = mutableSetOf("a"); mSet.add("b")
val mMap  = mutableMapOf("a" to 1); mMap["b"] = 2

// Decision guide:
// Need order + duplicates?    → List
// Need unique + fast lookup?  → Set
// Need key → value mapping?   → Map
// Need sorted order?          → sortedSetOf(), sortedMapOf()

List is ordered and allows duplicates. Elements are accessed by index. Use it for sequences, ordered results, feeds, or any data where position and repetition both matter. It is the default choice when you are not specifically filtering for uniqueness or looking things up by key.

Set holds unique elements with O(1) contains checks (backed by a hash set). Use it when you need fast membership testing or must eliminate duplicates — checking if an item ID has already been processed is much faster with a Set than scanning a List. Standard setOf() does not preserve insertion order; linkedSetOf() does.

Map stores key-value pairs with unique keys and O(1) lookup by key. Use it to build indexes, cache results keyed by ID, or group any data that needs fast retrieval by a known attribute. mapOf() does not guarantee ordering; linkedMapOf() preserves insertion order.

LinkedHashSet and LinkedHashMap give you the uniqueness and lookup benefits of Set/Map while preserving insertion order. They are the right choice when the order of insertion matters — for example, recent search history (unique queries, visible in recency order).

💡 Interview Tip

The choice between Set and List depends on whether duplicates matter and whether order matters. Map is the go-to for key-based lookup (O(1) with HashMap). In Kotlin, prefer immutable collections (listOf, setOf, mapOf) by default for thread safety and clarity of intent — use mutableListOf only when the collection genuinely needs to change after creation.

Mark it done or save it in the question bank · Back to top

Hard

47. What is Kotlin's object expressions vs object declarations vs companion objects — when to use each?

Kotlin's object keyword has three distinct use cases — each solves a different problem. Interviewers test whether you know the difference and can choose the right one.

// 1. Object DECLARATION — Singleton pattern
object DatabasePool {
    private val connections = mutableListOf<Connection>()
    fun getConnection(): Connection = connections.first()
}
// Thread-safe, lazy-initialized, one instance
DatabasePool.getConnection()

// 2. Object EXPRESSION — anonymous object (local, one-off)
val listener = object : View.OnClickListener {
    override fun onClick(v: View) { handleClick() }
}
// New instance each time — not a singleton
// Can capture variables from outer scope (closure)
val count = 0
val obj = object : Runnable {
    override fun run() { println(count) }  // captures count
}

// 3. Companion OBJECT — class-level members
class User(val name: String) {
    companion object Factory {  // optional name
        const val MAX_AGE = 150
        fun create(name: String) = User(name)
        fun guest() = User("Guest")
    }
}
User.create("Rahul")   // called on class
User.MAX_AGE             // constant
User.Factory.guest()    // via companion name

// Key differences:
// object declaration → singleton, global
// object expression  → local, new instance each time, captures scope
// companion object   → tied to a class, one per class

Object declaration creates a named singleton — initialized once lazily (on first access), thread-safe via class-loader guarantees on the JVM, and accessed by the object's name from anywhere. Use it for app-wide services: logging, config, connection pools.

Object expression creates a new anonymous instance every time the expression is evaluated — it is not a singleton. It can implement interfaces, extend classes, and capture variables from the enclosing scope (a closure). Use it for one-off listener callbacks and local interface implementations.

Companion object is a per-class singleton initialized when the containing class is loaded. Its members are accessed via the class name (User.create()), making it Kotlin's replacement for Java statics. There is exactly one companion per class, and it can implement interfaces — enabling factory and type-class patterns that Java statics cannot support.

The initialization timing distinction matters — object declarations are initialized lazily on first access; companion objects are initialized eagerly when the enclosing class is first loaded. For heavy initialization in a singleton, object declarations give you lazy loading for free. For constants on a class, companion object with const val is the standard choice.

💡 Interview Tip

All three use object keyword but have different semantics: object declaration = named singleton initialized once; object expression = anonymous instance created fresh each time the expression is evaluated; companion object = per-class singleton that can implement interfaces. The thread-safety interview angle: object declarations are JVM-class-loader safe; companion objects are initialized when the containing class is loaded.

Mark it done or save it in the question bank · Back to top

MediumMost asked

48. How do you write idiomatic Kotlin? What are the key code style principles?

Idiomatic Kotlin means using language features as intended — not writing Java-style code in Kotlin. Interviewers often show code and ask "how would you improve this?"

// ❌ Java-style in Kotlin
if (user != null) {
    println(user.name)
} else {
    println("unknown")
}

// ✅ Idiomatic
println(user?.name ?: "unknown")

// ❌ Verbose initialization
val intent = Intent(this, HomeActivity::class.java)
intent.putExtra("id", userId)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)

// ✅ Idiomatic with apply
startActivity(Intent(this, HomeActivity::class.java).apply {
    putExtra("id", userId)
    flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
})

// ❌ Manual null check
var name: String? = getName()
if (name != null && name.isNotEmpty()) { use(name) }

// ✅ let for null-safe block
getName()?.takeIf { it.isNotEmpty() }?.let { use(it) }

// ❌ Loops for transformations
val result = mutableListOf<String>()
for (user in users) {
    if (user.active) result.add(user.name)
}

// ✅ Functional
val result = users.filter { it.active }.map { it.name }

// ❌ is + explicit cast
if (x is String) use(x as String)
// ✅ Smart cast
if (x is String) use(x)  // no cast needed!

Replace null-checking if-else with ?. and ?: — user?.name ?: "unknown" is one line and reads clearly. The Java-style if (x != null) { ... } pattern exists in Kotlin but signals a code smell. Let the null safety operators do the work.

Use scope functions for initialization and null-safe blocks — apply for configuring an object (intent.apply { putExtra(...); flags = ... }), let for null-safe transformations (name?.let { use(it) }), also for side effects without changing the subject (list.also { log(it.size) }).

Replace imperative loops with collection functions. users.filter { it.active }.map { it.name } is more readable, less error-prone, and optimizable by the runtime than an explicit for loop with a mutableList. Reserve for loops for cases where you genuinely need to perform side effects in sequence.

Trust smart casts — after if (x is String), x is already a String inside the branch. No explicit cast (x as String) needed. Similarly, after a null check (if (x != null)), x is treated as non-nullable inside the block. Writing explicit casts after type checks is the Java habit to unlearn first.

Use data class copy() for immutable modifications. val updated = user.copy(name = "NewName") creates a modified copy without touching the original. This is the idiomatic Kotlin alternative to Java's manual "create a new object with most fields the same" boilerplate, and it integrates naturally with StateFlow-based state management.

💡 Interview Tip

Idiomatic Kotlin prioritizes: val over var, expression bodies over block bodies, scope functions for fluent chains, extension functions over utility classes, sealed classes for state, and data classes for value objects. The biggest idiom that impresses interviewers: 'I let the compiler enforce correctness rather than writing defensive runtime checks — sealed class when exhaustiveness, non-nullable types instead of null guards, require() instead of if-throw.'

Mark it done or save it in the question bank · Back to top

Hard

49. What are Kotlin's new features in 2024-2025 — Kotlin 2.0 highlights?

Kotlin 2.0 shipped in 2024 with major improvements to compilation speed, new language features, and the K2 compiler. Staying current with these is expected in senior 2025-26 interviews.

// K2 Compiler — Kotlin 2.0's biggest change
// 2x faster compilation
// Better IDE performance
// Improved type inference
// More accurate error messages

// Smart cast improvements (K2)
class Box(var value: Any)
val box = Box("hello")
if (box.value is String) {
    println(box.value.length)  // ✅ K2 smart-casts mutable var!
    // K1 didn't — had to use val copy
}

// Non-local break and continue (Kotlin 2.0+)
val list = listOf(1, 2, 3, 4, 5)
list.forEach {
    if (it == 3) return@forEach  // continue to next
    if (it == 4) return@forEach  // same pattern
    print(it)
}

// Power Assert (Kotlin 2.0 plugin)
// Provides detailed assertion failure messages
assert(user.name == "Rahul")
// Failure: assert(user.name == "Rahul")
//               |         |
//               "Alice"   false

// Data class copy improvements (Kotlin 2.x)
// Planned: copy() with named parameter visibility matching constructor

// Multiplatform stable (Kotlin 2.0)
// KMP officially stable for production use
// iOS, Android, Web, Server — one codebase for shared logic

// K2 kapt replacement — KSP2
// KSP2 built on K2 — even faster annotation processing

K2 compiler is the biggest structural change in Kotlin 2.0 — a complete rewrite of the compiler frontend delivering roughly 2x faster compilation, better IDE responsiveness, improved type inference, and more accurate error messages. It also enables features that were architecturally impossible to build cleanly on the old compiler.

Improved smart casts are one of K2's visible language improvements. The old compiler conservatively refused to smart-cast mutable var properties because another thread could theoretically change them between the check and the use. K2 is smarter about local reasoning and allows smart casts in more cases where safety can be proven.

Kotlin Multiplatform went stable in Kotlin 2.0, signaling production readiness for sharing business logic across Android, iOS, web, and server. Netflix, Cash App, and Touchlab are already running KMP in production. This is one of the most important ecosystem milestones in Kotlin's history.

Power Assert is a new compiler plugin (available as experimental in 2.0) that generates detailed failure messages for assert() calls, showing the intermediate values of every sub-expression — similar to what Groovy's Power Assert has had for years. This dramatically improves test debuggability.

KSP2 is built on the K2 compiler APIs and processes Kotlin symbols faster than kapt ever could. With K2 as the foundation, annotation processing can work directly with Kotlin's type model rather than going through a Java stub compilation step. Migrate to KSP2 when your libraries support it.

💡 Interview Tip

Kotlin 2.0's headline feature is the new K2 compiler — up to 2x faster compilation. Other highlights: stable value classes, when with guard conditions (when (x) { is Int if x > 0 -> ... }), improved smart casts across complex conditions, and multiplatform improvements. For Android specifically: K2 mode in the IDE improves code analysis performance significantly — a concrete benefit worth mentioning.

Mark it done or save it in the question bank · Back to top

Hard

50. How does Kotlin Multiplatform work? What can and cannot be shared across platforms?

KMP (Kotlin Multiplatform) lets you share business logic across Android, iOS, web, and server while keeping platform-specific UI native. It became stable in Kotlin 2.0 and is rapidly being adopted in 2025.

// KMP project structure
// shared/
//   commonMain/   ← shared Kotlin code
//   androidMain/  ← Android-specific
//   iosMain/      ← iOS-specific
// androidApp/     ← Android UI (Compose)
// iosApp/         ← iOS UI (SwiftUI)

// CAN SHARE:
// ✅ Data models, business logic, use cases
// ✅ Repository pattern
// ✅ Networking (Ktor)
// ✅ Database (SQLDelight)
// ✅ JSON parsing (kotlinx.serialization)
// ✅ Coroutines, Flow

// CANNOT SHARE (platform-specific):
// ❌ UI (Compose Android vs SwiftUI iOS)
// ❌ Camera, GPS, Bluetooth
// ❌ Push notifications

// expect/actual — platform implementations
// commonMain
expect fun currentTimeMillis(): Long
expect class PlatformContext

// androidMain
actual fun currentTimeMillis() = System.currentTimeMillis()
actual class PlatformContext(val context: android.content.Context)

// iosMain
actual fun currentTimeMillis() = NSDate().timeIntervalSince1970.toLong() * 1000
actual class PlatformContext  // no context needed on iOS

// Shared repository — used by both Android and iOS
class UserRepository(
    private val api: UserApi,    // Ktor — multiplatform
    private val db: UserDatabase  // SQLDelight — multiplatform
) {
    suspend fun getUser(id: String) = api.fetchUser(id)
}

What you can share — business logic, data models, repositories, use cases, networking (Ktor), database (SQLDelight), JSON parsing (kotlinx.serialization), coroutines, and Flow. In a well-structured KMP project this is typically 60–70% of the total codebase.

What stays platform-specific — UI (Compose on Android, SwiftUI on iOS), camera, GPS, Bluetooth, and push notifications. KMP's philosophy is to share logic while keeping native UI for the best user experience on each platform. Compose Multiplatform (by JetBrains) extends UI sharing to desktop and web and is maturing for iOS.

expect / actual is the mechanism for platform-specific implementations. In commonMain, you declare an expect declaration — a function or class without a body. Each platform provides an actual implementation. The compiler enforces that every expect has a matching actual on every configured target.

KMP went stable in Kotlin 2.0 — officially production-ready. Netflix, Cash App, Touchlab, and Square have all shipped KMP in production apps. The tooling (KMP Wizard in Android Studio, Xcode integration for iOS) has matured significantly. This is no longer a bet on the future; it is a viable architecture choice today.

💡 Interview Tip

KMP is gaining serious traction — Netflix, Cash App, and Touchlab use it in production. The key selling point: "write business logic once in Kotlin, share it between Android and iOS, keeping native UI on each platform for best user experience." Mention that KMP went stable with Kotlin 2.0, and that Compose Multiplatform (by JetBrains) extends sharing to UI as well, though the iOS side is still maturing. This shows both practical knowledge and awareness of ecosystem trajectory.

Mark it done or save it in the question bank · Back to top

More Android interview topics

See all 600+ questions in the question bank