Interview Prep/Kotlin

45 Kotlin Interview Questions & Answers (2025) — Coroutines, Flow & Android

Prepare for Kotlin/Android interviews with 45 questions covering null safety, coroutines, Kotlin Flow, Jetpack Compose, and advanced language features — from beginner to senior level.

42 Questions~30 min read8 CategoriesUpdated 2025
Practice Kotlin Quiz

Language Basics

01 · 7q

Kotlin eliminates NullPointerExceptions through its type system. Non-nullable types (String) cannot hold null. Nullable types require explicit declaration (String?). Key operators: safe call (?.) returns null instead of throwing; Elvis operator (?:) provides a fallback value; not-null assertion (!!) throws KotlinNullPointerException if null — use sparingly. The let scope function runs a block only when the value is non-null. lateinit var allows non-null initialization after construction for lifecycle-managed objects.

val: read-only reference — cannot be reassigned after initialization, but the object it points to can be mutable. var: mutable — can be reassigned. const val: compile-time constant — evaluated at compile time, must be a primitive or String, must be top-level or inside an object/companion object. Rule of thumb: prefer val everywhere; use var only when mutation is required; use const val for truly constant values like API keys or magic numbers.

String templates embed expressions directly in strings with the $ prefix. Simple variable: "Hello, $name". Arbitrary expression: "Result: ${a + b}" or "Length: ${text.length}". No string concatenation needed. Templates work in regular and raw strings (triple-quoted). For raw strings with multiline content, use trimIndent() or trimMargin() to remove leading whitespace. This is more readable and less error-prone than Java string concatenation.

when is more powerful than Java switch. It works as both a statement and an expression (returns a value). It supports: arbitrary conditions (not just constants), ranges (in 1..10), type checks (is String), multiple values per branch (1, 2 -> ...), and no fall-through (no break needed). When used as an expression, the else branch is required unless the compiler can prove exhaustiveness (e.g., sealed classes). Example: val desc = when(score) { in 90..100 -> "A" in 80..89 -> "B" else -> "F" }.

Default parameters allow function arguments to have fallback values, eliminating the need for multiple overloads. Named parameters let callers specify arguments by name in any order, greatly improving readability for functions with many parameters. Example: fun createUser(name: String, role: String = "guest", active: Boolean = true). Call with: createUser(name = "Alice", active = false). When calling from Java, @JvmOverloads annotation instructs the compiler to generate overloaded Java methods for each default parameter combination.

Interfaces: can have abstract and default methods, can hold state via abstract properties (but not backing fields). A class can implement multiple interfaces. Abstract classes: can have abstract and concrete methods with full state (backing fields). Single inheritance only. Key difference from Java: Kotlin interfaces can contain method implementations (default methods), blurring the line with abstract classes. Choose interface when defining a capability contract (Serializable, Clickable); choose abstract class when sharing significant implementation and state across a hierarchy.

At the top: Any? (nullable any) is the root of all Kotlin types; Any (non-nullable) is the root of all non-nullable types. Every Kotlin type is a subtype of Any?. At the bottom: Nothing is a subtype of every type — a function that throws always returns Nothing; it's used in the type system to represent 'no value ever returned'. Unit: the return type for functions that return no meaningful value (equivalent to Java void). Nothing?, Null: the type of the null literal, a subtype of every nullable type. Primitive types (Int, Long, etc.) compile to JVM primitives when possible, boxed to Integer/Long when used in generics.

Language Features

02 · 16q

Declaring a class with the data modifier auto-generates: equals() and hashCode() based on primary constructor properties; toString() as 'ClassName(prop=value, ...)'; copy() which creates a new instance with optional property overrides; and componentN() functions enabling destructuring declarations. Requirements: at least one parameter in the primary constructor, all parameters must be val or var. Inherited properties from the body are excluded from generated functions — only primary constructor params count.

Extension functions let you add new functions to existing classes without modifying them or using inheritance. Syntax: fun ClassName.functionName(params): ReturnType { }. The receiver object (this) is available inside the function body. Extensions are resolved statically at compile time — they don't actually modify the class, which means they cannot override members and don't have access to private members. Common use: adding utility methods to library types like String, List, or Context in Android. Extensions are a core Kotlin idiom that replaces Java utility classes.

Scope functions execute a block on an object. Key differences: let — receiver is 'it', returns lambda result, used for null checks and transformations; run — receiver is 'this', returns lambda result, used to initialize and compute; apply — receiver is 'this', returns receiver, used for object configuration (builder pattern); also — receiver is 'it', returns receiver, used for side effects without changing the chain; with — non-extension, receiver is 'this', returns lambda result, used when you have the object already and want to call multiple methods. Rule: apply/also for configuration, let/run for transformation, with for multiple operations on same object.

Enum classes represent a fixed set of instances — each constant is a singleton with the same type. Sealed classes represent a restricted class hierarchy — each subclass can have its own state and multiple instances. Sealed class subclasses can be data classes, objects, or regular classes. When used in a when expression, the compiler enforces exhaustiveness, eliminating the need for else. Use enum when you need a fixed set of named constants with shared behavior; use sealed class when each variant needs different data or complex logic. Sealed interfaces (Kotlin 1.5+) allow subclasses in multiple files within the same module.

Higher-order functions accept or return other functions. Lambda syntax: { params -> body }. For single-parameter lambdas, 'it' can be used implicitly. Function types are expressed as (ParamType) -> ReturnType. Last-lambda convention: if the final parameter is a function, the lambda can be placed outside the parentheses — enabling DSL-style APIs like forEach { }, apply { }, or custom builders. Lambdas capture variables from the enclosing scope (closures), unlike Java where only effectively final variables can be captured.

Companion objects are singleton objects declared inside a class using companion object { }. They can access the enclosing class's private members. Members are accessed via the class name (ClassName.method()), similar to static. However, companion objects are true objects — they can implement interfaces, be stored in variables, and have extension functions defined on them. Unlike Java's static, companion object members are instance members of a singleton. @JvmStatic annotation makes members accessible as real Java statics for Java interop. Factory methods are a common pattern: companion object { fun create(): MyClass = MyClass() }.

Inline functions: the compiler copies the function body at each call site instead of generating a function call. This eliminates the overhead of lambda object creation and virtual dispatch, making higher-order functions essentially free. The noinline modifier prevents specific lambda parameters from being inlined; crossinline prevents non-local returns from inlined lambdas. Reified type parameters: normally type parameters are erased at runtime. Marking a type parameter as reified (only possible in inline functions) allows accessing it at runtime — enabling constructs like if (value is T) or inline fun <reified T> Gson.fromJson(json: String) = fromJson(json, T::class.java).

Kotlin supports two kinds of delegation. Interface delegation: a class implements an interface by delegating to another object — class MyList<T>(private val impl: List<T>) : List<T> by impl. This avoids boilerplate when you want to decorate an existing implementation. Property delegation: a property delegates get/set to a delegate object that has getValue/setValue operators. Built-in delegates: lazy (thread-safe lazy initialization), observable (triggers callback on change), vetoable (allows rejecting changes), and Map (backing a property with a map entry). Custom delegates can be created for logging, validation, or caching.

Delegated properties outsource the get/set logic to a delegate object. Syntax: val/var name: Type by DelegateExpression. Built-in delegates: lazy { } — executes the lambda on first access, caches the result; thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED). Delegates.observable — calls a callback on every assignment. Delegates.vetoable — calls a callback and allows rejecting the new value. Map/MutableMap — property backed by a map entry, commonly used for JSON-like structures. Custom delegates implement ReadOnlyProperty or ReadWriteProperty. Use cases: lazy initialization of expensive resources, observable view models, preference-backed properties.

Kotlin generics are invariant by default — List<String> is not a subtype of List<Any>. Covariance (out): a generic type can only produce (return) T, making Producer<Cat> a subtype of Producer<Animal>. Syntax: class Box<out T>. Contravariance (in): a generic type can only consume T, making Consumer<Animal> a subtype of Consumer<Cat>. Syntax: class Box<in T>. Declaration-site variance declares this in the class definition; use-site variance can be applied at the call site with out/in modifiers. Star projection (*): used when the type argument is unknown, equivalent to <out Any?> for producing types. Kotlin's List<T> is covariant (out T) while MutableList<T> is invariant.

Object declarations create singletons: object MySingleton { val x = 42 }. The singleton is initialized lazily on first access, thread-safe by default. Object expressions create anonymous objects: val runnable = object : Runnable { override fun run() { } }. Unlike Java anonymous classes, Kotlin object expressions can implement multiple interfaces and access (and modify) variables from the enclosing scope. Object expressions are useful for adapters, listeners, and test fakes. The difference from companion objects: object declarations are top-level or nested singletons; companion objects are bound to a specific enclosing class.

Value classes (formerly inline classes) wrap a single value and add type safety without runtime overhead. Declared with @JvmInline value class UserId(val id: String). At runtime, the wrapper is replaced by the underlying value (inlined) wherever possible, avoiding object allocation. Useful for: domain primitives (UserId vs plain String), safe units (Meters vs Kg), avoiding primitive obsession. Limitations: can only hold a single val; cannot extend other classes; may be boxed in some generic contexts. Different from data classes: no equals/hashCode/copy based on the wrapped value — you implement them manually.

Kotlin DSLs use a combination of extension functions, lambda receivers, and operator overloading. Key pattern: a builder function that accepts a lambda with receiver — fun buildHtml(block: HtmlBuilder.() -> Unit): HtmlBuilder. Inside the lambda, the receiver's members are accessible without qualification. The @DslMarker annotation prevents nested DSL receivers from implicitly accessing outer scopes, avoiding confusing call chains. Example: Gradle Kotlin DSL, Ktor routing DSL, Kotlin HTML, Compose's remember { } or LazyColumn { items { } }. DSLs make code look declarative while remaining type-safe.

Kotlin allows overloading a fixed set of operators using the operator modifier. Common operators: plus (+), minus (−), times (*), unaryMinus (−x), compareTo (<,>), get (obj[i]), set (obj[i] = v), rangeTo (..), contains (in). Iterator operators (iterator, hasNext, next) enable for-loop support on custom types. The invoke operator lets an object be called like a function: class Multiplier(val factor: Int) { operator fun invoke(x: Int) = x * factor }; then val double = Multiplier(2); double(5) // 10. invoke is used in Compose (remember { }), function types, and factory patterns.

Kotlin has immutable (List, Set, Map) and mutable (MutableList, MutableSet, MutableMap) collection interfaces. Core operators: map — transforms each element; filter — keeps elements matching a predicate; reduce/fold — aggregates to a single value (fold takes an initial value); forEach — iterates with side effects; flatMap — maps then flattens; groupBy — partitions into a map; sortedBy/sortedWith — ordering; any/all/none — boolean predicates; find/first/firstOrNull — element lookup; zip — pairs elements from two collections. Kotlin collections are lazy when chained with asSequence(), avoiding intermediate allocations — important for large collections.

Sealed interfaces (Kotlin 1.5+) extend sealed classes to interfaces. Key differences: a class or object can implement multiple sealed interfaces (single inheritance still limits sealed classes); sealed interface subclasses can be spread across multiple files within the same module (vs sealed classes which require subclasses in the same file pre-Kotlin 1.5, or same package post-1.5). Use sealed interfaces when you want exhaustive when expressions over a type hierarchy that includes classes implementing multiple sealed supertypes. Common pattern: modeling UI states (Loading, Success, Error) or result types.

Coroutines

03 · 8q

Coroutines are lightweight, non-blocking concurrency primitives. Unlike threads, they don't map 1:1 to OS threads — thousands can run on a small thread pool. Benefits: sequential style for async code (no callback nesting); structured concurrency — coroutine lifetime is scoped to its parent, preventing leaks; built-in cancellation propagation; and composable with suspend functions. Core builders: launch (fire-and-forget, returns Job); async (returns Deferred<T>, use .await() for result). Coroutines are not a language feature added to the runtime but a library built on compiler-generated state machines.

launch starts a coroutine that doesn't return a result — it returns a Job that can be used for cancellation and lifecycle management. It's fire-and-forget for side effects. async starts a coroutine that computes a result — it returns Deferred<T>. Call .await() on the Deferred to suspend and get the value. async enables parallel decomposition: launching multiple async blocks and awaiting all results. Exception handling differs: in launch, uncaught exceptions propagate immediately to the parent; in async, exceptions are stored in the Deferred and rethrown when .await() is called.

Dispatchers control which thread(s) a coroutine runs on. Dispatchers.Main: runs on the main/UI thread — use for UI updates in Android. Dispatchers.IO: optimized for blocking I/O operations (network, disk, database) — uses a shared pool that can grow up to 64 threads. Dispatchers.Default: optimized for CPU-intensive work (sorting, parsing) — uses a pool with threads equal to CPU cores. Dispatchers.Unconfined: starts in the calling thread and resumes in whatever thread resumed it — avoid in production. Use withContext(Dispatcher) to switch context within a suspend function without creating a new coroutine.

Structured concurrency ensures that coroutines follow a parent-child hierarchy where children must complete before the parent. Rules: a coroutine can only be launched inside a CoroutineScope; if a child fails, the parent and all siblings are cancelled; if the parent is cancelled, all children are cancelled. This prevents coroutine leaks and makes lifecycle management predictable. In Android, ViewModelScope and LifecycleScope provide structured scopes tied to UI lifecycle. Contrast with GlobalScope, which launches orphan coroutines with no lifecycle bounds — avoid it.

Exception handling in coroutines depends on the builder. In launch: uncaught exceptions propagate to the parent scope and cancel siblings. Wrap the body in try/catch, or attach a CoroutineExceptionHandler to the scope. In async: exceptions are stored in the Deferred and rethrown on .await() — use try/catch around await(). SupervisorJob changes the failure policy: a child failure does not cancel the parent or siblings. supervisorScope { } creates a scope with SupervisorJob semantics. Use CoroutineExceptionHandler as a last resort for logging, not for recovery — it only works with launch in a root scope.

Suspend functions can be paused and resumed without blocking a thread. They can only be called from coroutines or other suspend functions. The compiler transforms them into state machines with a Continuation parameter. A suspend function does not inherently execute on a background thread — you still need to specify a Dispatcher via withContext(). A regular blocking function called from a coroutine will block the thread it runs on. Use Dispatchers.IO to call blocking code from a coroutine without blocking the UI thread.

Job: default coroutine job. If a child coroutine fails with an uncaught exception, the failure propagates to the parent Job, which cancels all other children. Useful when one failure means the whole operation should abort. SupervisorJob: failures in children do not propagate to the parent or cancel siblings. Each child is independent. Use SupervisorJob (or supervisorScope) when children are independent tasks and one failure should not affect others — e.g., loading multiple independent sections of a UI. ViewModelScope uses SupervisorJob internally.

CoroutineScope: a scoped lifetime tied to some entity (ViewModel, Activity, a specific operation). Coroutines launched in it are automatically cancelled when the scope is cancelled, preventing memory leaks. GlobalScope: a top-level scope with the lifetime of the whole application. Coroutines launched here are orphaned — they have no parent and are not cancelled when UI components are destroyed. Avoid GlobalScope in Android production code. Create custom scopes with CoroutineScope(Dispatchers.IO + SupervisorJob()), and cancel them when the owning entity is destroyed.

Flow

04 · 4q

Flow is a cold asynchronous data stream built on coroutines. LiveData is Android-specific, lifecycle-aware, and always holds the latest value. Flow is pure Kotlin (no Android dependency), supports backpressure, and has rich operators. Flow is cold — execution starts only when collected; LiveData is hot. Flow collection is tied to a coroutine scope giving explicit lifecycle control. With repeatOnLifecycle(Lifecycle.State.STARTED), Flow can be made lifecycle-safe on Android. Prefer Flow for new code; it's more composable and testable than LiveData.

Cold flows: the flow body executes fresh for each collector — like a function call. Created with flow { emit() }. Multiple collectors each get their own independent execution. No value is produced until a collector subscribes. Hot flows: active regardless of collectors and share the same execution. StateFlow and SharedFlow are hot. StateFlow holds a current value and replays it to new collectors. SharedFlow configures replay and buffer. Use cold flows for one-shot or per-collector streams (API calls, DB queries); use hot flows for events and state that multiple components observe.

StateFlow: a hot flow that always holds a current value (like LiveData). Always replays the latest value to new collectors. Requires an initial value. Collectors only receive distinct values (conflated). Used for UI state in ViewModels. SharedFlow: a hot flow with configurable replay (number of past values replayed to new collectors) and buffer. Does not require an initial value. Not conflated — emits every value. Used for one-time events (navigation, toast messages), or broadcast scenarios. MutableStateFlow and MutableSharedFlow are mutable versions for emitting from the ViewModel.

Transform operators: map (transforms each value), filter (passes values matching a predicate), flatMapLatest (switches to a new flow for each value, cancels previous), flatMapConcat (serializes inner flows), flatMapMerge (merges inner flows concurrently). Combining operators: combine (combines latest values from multiple flows), zip (pairs values 1:1), merge (merges multiple flows). Control flow: take (limits items), debounce (drops values if new one arrives within timeout — good for search), distinctUntilChanged (skips repeated identical values). Buffer/backpressure: buffer (runs upstream in separate coroutine), conflate (keeps only latest), collectLatest (cancels slow collector when new value arrives).

Android

05 · 4q

Compose is Android's modern declarative UI toolkit. UI is described as composable functions annotated with @Composable. Recomposition: when state changes, Compose only re-executes the composable functions that depend on that state. vs XML: Compose is code (type-safe, no layout inflation), XML is markup (verbose, requires ViewBinding/DataBinding). Compose supports previews in Android Studio, animations are first-class, and theming uses MaterialTheme. Migration path: ComposeView wraps Compose in XML; AndroidView wraps Views inside Compose. Google recommends Compose for all new Android UI.

ViewModel survives configuration changes (screen rotation) by living in the ViewModelStore. It exposes state to the UI via StateFlow/LiveData and contains business logic. ViewModels should not hold references to Activity/Fragment to avoid memory leaks. viewModelScope is a CoroutineScope that auto-cancels when the ViewModel is cleared. In Compose, viewModel() provides the ViewModel tied to the NavBackStackEntry or Activity. The MVVM (or MVI) pattern with ViewModel separates UI from logic, making code testable and lifecycle-safe.

Hilt is the recommended DI framework for Android, built on Dagger. Setup: annotate the Application class with @HiltAndroidApp. Inject into Activities/Fragments/ViewModels with @AndroidEntryPoint. Declare bindings in @Module / @InstallIn classes. Use @Inject on constructors for automatic binding. Scopes: @Singleton (application-wide), @ActivityScoped, @ViewModelScoped, etc. ViewModels get HiltViewModel annotation and can receive injected dependencies. Benefits over manual DI: compile-time validation, no boilerplate for common patterns, first-class Compose and WorkManager support.

Room is an SQLite abstraction layer. Components: @Entity (data class mapped to a table), @Dao (interface with @Query/@Insert/@Update/@Delete), @Database (abstract class extending RoomDatabase). With coroutines: suspend functions in Dao are automatically run off the main thread. With Flow: a @Query returning Flow<List<T>> emits a new list whenever the underlying table changes — ideal for reactive UIs. RoomDatabase.Builder creates the database with migrations. TypeConverters handle non-primitive types. In tests, use an in-memory database: Room.inMemoryDatabaseBuilder().

Kotlin Multiplatform

06 · 1q

KMP allows sharing Kotlin code across Android, iOS, JVM, JS, and native targets. The commonMain source set contains shared logic: business logic, networking, data models, use cases. Platform-specific code lives in androidMain, iosMain, etc. expect/actual mechanism: declare an expected declaration in commonMain, provide actual implementations per platform. What you can share: ViewModels, repository layer, networking (Ktor), serialization (kotlinx.serialization), database (SQLDelight). What stays platform-specific: UI (Compose on Android, SwiftUI on iOS), platform APIs. KMP compiles to native code on iOS (via Kotlin/Native), not interpreted.

Java Interop

07 · 1q

Kotlin is fully interoperable with Java. Kotlin can call Java code directly; Java can call Kotlin with some annotations for smooth interop. Key annotations for Java callers: @JvmStatic makes companion object members true Java static methods; @JvmOverloads generates overloaded methods for default parameters; @JvmField exposes a property as a Java field (no getter/setter); @Throws declares checked exceptions (Kotlin has none natively). Kotlin top-level functions compile to static methods in a class named FileName.kt → FileNameKt. Extension functions appear as static methods with the receiver as the first parameter.

Testing

08 · 1q

Use the kotlinx-coroutines-test library. runTest { } replaces runBlocking for coroutine tests — it uses a virtual time scheduler that advances time instantly for delay/timeout. TestCoroutineDispatcher (deprecated) or UnconfinedTestDispatcher / StandardTestDispatcher control when coroutines execute. For ViewModels: set the Main dispatcher to a test dispatcher in setUp with Dispatchers.setMain(testDispatcher) and reset in tearDown. For Flow: use turbine library — val turbine = flow.testIn(this) then turbine.awaitItem(), awaitComplete(), cancel(). Inject dispatchers rather than hardcoding them for testability.

Ready to test your Kotlin skills?

Practice with interactive quizzes and get instant feedback.

Start Free Practice