Skip to content
>_ StartupJobList_

Interview questions · Worked answers

Kotlin interview questions with worked answers

Strong Kotlin answers explain the boundary behind the concise syntax: whether a Java value can be null, whether a copy shares nested state, which variance the API promises, what work is eager, and which scope owns a coroutine. For asynchronous code, name the dispatcher, cancellation path, error policy and shared-state protection. Kotlin makes several contracts visible, but Java interop and blocking operations still require explicit design.

For: Mid-level to senior Kotlin/JVM application engineers; coroutine examples use kotlinx.coroutines APIs.

Guide scope and versions

This guide focuses on Kotlin on the JVM and its interaction with Java. It separates Kotlin language features from kotlinx.coroutines library behavior. Coroutine dispatchers do not make blocking APIs non-blocking, and cancellation remains cooperative at suspension or explicit checks. It does not prescribe a specific framework or treat Kotlin-tagged postings as a role salary survey.

Question 1 of 10

Why can a Kotlin call still throw a null-pointer exception?

Kotlin's nullable types make nullability explicit inside Kotlin declarations, but a Java reference without recognized nullability annotations enters Kotlin as a platform type. The compiler permits it to be treated as nullable or non-null, so a value that is actually null can still fail at runtime. Tighten the contract where Java data crosses into Kotlin instead of assuming the type system has proved more than the annotations state.

A Java method returning an unannotated String may return null even though Kotlin sees a flexible platform type, often displayed by tools as String!. Assigning that result to a non-null String can compile, then fail when a generated check rejects null. Calling a method directly on the platform value can also fail. Platform types are a compatibility bridge, not a third source-level nullability syntax and not proof that the library behaves consistently.

At a boundary, choose the meaning deliberately: declare a nullable type and handle absence, validate and convert to a non-null domain type, or improve the Java API with supported nullability annotations. Check generic type arguments as well as outer references; a Java collection can contain null even if the Kotlin variable itself is non-null. Avoid scattering !! because it turns an undocumented assumption into a crash location rather than a validated contract.

Kotlin/JVM · Worked example
// Java library: String findName() may return null
val raw = legacy.findName()      // platform type
val display: String = raw        // compiles, may throw if null

val safe: String? = legacy.findName()
val label = safe ?: "Unknown"

The annotation-free Java return does not give Kotlin a reliable nullable or non-null contract. Assigning to String asks for a non-null value and can produce a runtime check; assigning to String? keeps absence representable so the call site can handle it. If the value should never be absent, validate once at the adapter boundary and report a meaningful domain error.

Compiled and executed in an isolated fixture.

Follow-up: What is the best fix if many Kotlin callers see platform types from one Java module?

Add accurate nullability annotations to the Java API or create one Kotlin adapter that validates the legacy contract and exposes explicit nullable or non-null domain types. Validate collection elements too. The right choice depends on whether null is a valid result or a defect in that module.

Read the source: Calling Java from Kotlin: null-safety and platform types · Kotlin null safety and Java interoperation

↑ Back to questions

Question 2 of 10

What do data-class equality and `copy` include?

A Kotlin data class generates equals, hashCode, destructuring components and copy from properties declared in its primary constructor. copy is shallow: it creates a new outer object but reuses references held by its properties. Properties declared only in the class body do not participate in generated equality or copying.

Generated equality is convenient when the constructor properties are the intended value identity. Moving a property into the class body changes that contract: two instances can compare equal even when the body property differs, and copy will not transfer it. This can be surprising in caches, test assertions or serialization adapters. Treat the primary constructor as the value contract, not merely a compact parameter list.

A val prevents reassignment of that reference; it does not make the referenced object immutable. A data class that stores a mutable list can share it across the original and a copied instance. Mutating the list changes what both objects observe and can change their hash code if it participates in equality. Defensive copies or immutable collection APIs can establish a snapshot, but copying the collection does not recursively copy mutable elements.

Kotlin/JVM · Worked example
data class Team(val name: String, val members: MutableList<String>)

val first = Team("Search", mutableListOf("Ari"))
val second = first.copy()
second.members += "Bo"
// first.members and second.members both contain [Ari, Bo]

first == second // true: both see the same member list

copy() creates another Team, but both members properties refer to the same mutable list. The example's equality stays true because both objects observe that same list value. If the property had been changed independently in two instances, equality and hash behavior could change over time.

Compiled and executed in an isolated fixture.

Follow-up: How can a data class represent a stable snapshot of members?

Copy the input collection at construction and expose a read-only or unmodifiable view, while ensuring the elements themselves have stable values. If elements are mutable, map them to immutable snapshot values or explicitly define shared ownership.

Read the source: Kotlin data classes · Kotlin collections overview

↑ Back to questions

Question 3 of 10

How do variance and `reified` types affect a Kotlin API?

Kotlin lets a type parameter declare how a generic type can vary: out T is a producer, in T is a consumer, and an invariant parameter supports both directions. Use-site projections can narrow how one value is used. Generic arguments are erased on the JVM; an inline function with a reified parameter can make limited type checks at the inlined call site, but it does not make arbitrary objects retain their generic arguments.

A producer that only returns T can safely be covariant: a producer of Cat can be used where a producer of Animal is expected. A consumer that only accepts T can be contravariant: a handler able to accept any Animal can handle a Cat. A mutable container both reads and writes, so treating MutableList<Cat> as MutableList<Animal> would allow a dog to be inserted into a cat list. Kotlin's declaration-site out and in make these restrictions part of the type declaration; Java wildcard signatures are commonly translated into Kotlin projections at interop boundaries.

On the JVM, ordinary generic type arguments are not generally available as runtime object identity. Kotlin therefore rejects checks such as value is List<String> because the element argument has been erased. An inline function declared inline fun <reified T> ... can use the substituted T for supported checks at the call site. This is useful for APIs such as a JSON decoder selecting a target class, but it has limits: it does not inspect the contents of an arbitrary collection, and reified parameters require inlining.

Kotlin/JVM · Worked example
interface Source<out T> { fun next(): T }
interface Sink<in T> { fun accept(value: T) }

fun <T> move(source: Source<T>, sink: Sink<T>) {
    sink.accept(source.next())
}

inline fun <reified T> isValue(value: Any): Boolean = value is T
// isValue<String>("ok") is true
// value is List<String> is not a valid reified check

Source only produces T, so its parameter can be covariant; Sink only consumes T, so it can be contravariant. The reified check can compare a value against the concrete substituted class at the inline call site. The JVM still does not retain the element type of a general list, so checking its contents requires an explicit validation pass.

Compiled and executed in an isolated fixture.

Follow-up: When would you pass a KClass<T> instead of making a function inline and reified?

Pass a class token when the operation must be stored, passed across a non-inline boundary, or exposed without inlining the caller. Use a serializer or schema when validation needs generic structure such as List<Order>; a plain class token can identify only the runtime class it represents.

Read the source: Kotlin generics and variance · Kotlin inline functions and reified type parameters · Kotlin and Java generics interoperation

↑ Back to questions

Question 4 of 10

When is a Kotlin Sequence different from a collection pipeline?

Standard collection transformation functions such as map and filter produce results eagerly, usually allocating an intermediate collection at each step. A Sequence composes many operations lazily and evaluates elements as a terminal operation requests them. Laziness can reduce intermediate allocations or stop early, but adds iterator and dispatch overhead and does not make every pipeline faster.

For a small in-memory list, eager operations are often simpler and may be faster because each pass is straightforward and optimized. For a large input or a chain that can reject most elements and stop after a match, a sequence can avoid materializing every intermediate list and can short-circuit. The right comparison includes input size, pipeline depth, allocation pressure, early termination and readability. Benchmark the real workload before introducing laziness as a performance claim.

A sequence is not automatically a snapshot. A sequence created from a mutable collection may observe that collection as it is traversed, and a sequence can be one-shot when backed by a one-use source such as a file iterator. Resource-backed sequences need explicit lifetime management; returning a lazy sequence after closing its underlying stream creates a delayed failure. When the source has external resources, consume it inside the resource scope and return materialized results or a lifecycle-aware abstraction.

Kotlin/JVM · Worked example
val seen = mutableListOf<Int>()
val result = sequenceOf(1, 2, 3)
    .onEach { seen += it }
    .filter { it > 1 }
// seen is []: no terminal operation yet
val first = result.first()
// first is 2; seen is [1, 2]

The sequence pipeline runs only when first() requests a result. It consumes 1, then 2, and stops as soon as the filter yields a match. This onEach is diagnostic for the example; production decisions should not depend on side effects hidden in a lazy chain.

Compiled and executed in an isolated fixture.

Follow-up: How would you decide whether to keep a sequence pipeline?

Compare eager and lazy versions on representative input sizes and the actual terminal operation. Account for stateful stages such as sorted(), which still need all upstream elements and buffer them before producing output, then measure allocation and elapsed time and verify source lifetime and mutation behavior.

Read the source: Kotlin sequences · Kotlin collections overview

↑ Back to questions

Question 5 of 10

What does structured concurrency guarantee about child coroutines?

A coroutine scope establishes a parent-child lifecycle. A parent normally waits for its children, and cancellation of the parent propagates to children. This makes ownership and cleanup easier to follow, but cancellation is cooperative: code must reach a cancellable suspension point or check cancellation, and cleanup must not swallow the signal indefinitely.

A request, screen or background job should have a scope whose lifetime matches that work. Launching child operations in that scope lets the owner cancel the work when the request is abandoned and wait for children before completing. A detached global scope discards that ownership relationship: failures and work can outlive the caller, which is rarely correct for request-scoped operations. Choose a supervisor-style policy only when sibling failures should be isolated by design, not as a blanket way to hide errors.

Cancellation is represented through CancellationException and is propagated through suspending operations. A CPU loop that never suspends can keep running unless it checks isActive or calls ensureActive. A finally block is the right place for synchronous cleanup, but a suspending cleanup operation may need a carefully bounded non-cancellable section. Do not catch a broad exception and turn cancellation into an ordinary result; preserve cancellation after performing required cleanup.

Kotlin/JVM with kotlinx.coroutines · Worked example
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val profile = async { api.profile() }
    val alerts = async { api.alerts() }
    Dashboard(profile.await(), alerts.await())
}

// If this scope is cancelled, both children are cancelled.
// A CPU-only child must also check cancellation while looping.

coroutineScope ties both child tasks to the caller's operation and waits for them. If either child fails under the normal scope policy, the scope fails and sibling work is cancelled. Whether the underlying HTTP operation stops promptly depends on the client adapter and its cancellation integration.

Compiled and executed in an isolated fixture.

Follow-up: What should a catch block do if it needs to log a coroutine failure?

Handle the expected failure types, perform local cleanup, and rethrow cancellation. If a broader handler is required, test that CancellationException is propagated and that the parent still observes the intended failure policy.

Read the source: Coroutines basics: scope and structured concurrency · Kotlin cancellation and timeouts · Coroutine exception handling

↑ Back to questions

Question 6 of 10

Does moving blocking work to a dispatcher make it asynchronous?

Moving blocking code to an appropriate dispatcher can keep it from blocking a caller thread, but the blocking call still occupies a thread for its duration. A dispatcher chooses where coroutine code runs; it does not transform a JDBC or legacy socket API into non-blocking I/O. Use a bounded strategy for blocking work and preserve cancellation and capacity limits at the underlying client.

A suspending function may pause without occupying its thread while awaiting a genuinely asynchronous operation. A normal Java call such as a blocking database query does not suspend merely because it is called inside a coroutine. If it runs on a UI or request event-loop thread, it can stall unrelated work. withContext(Dispatchers.IO) is a common way to move blocking JVM work away from a CPU-oriented dispatcher, but it does not create unlimited database connections or guarantee the blocking API responds to cancellation.

Keep the boundary explicit. Wrap blocking libraries in a small adapter that chooses an appropriate dispatcher, applies the client's own timeout, closes resources and maps cancellation behavior. Avoid repeatedly switching dispatchers through every layer; let the layer that knows the blocking nature own that decision. Bound concurrency using the connection pool or an explicit admission limit. Do not simply add threads until latency improves, because that can increase contention and downstream overload.

Kotlin/JVM with kotlinx.coroutines · Worked example
suspend fun loadRecord(id: Id): Record = withContext(Dispatchers.IO) {
    jdbcConnection.use { connection ->
        connection.prepareStatement("SELECT record_value FROM records WHERE id = ?").use { statement ->
            statement.setInt(1, id.value)
            statement.executeQuery().use { rows ->
                check(rows.next()) { "record not found" }
                rows.toRecord()
            }
        }
    }
}

The blocking JDBC work is moved to the IO dispatcher and the connection, prepared statement and result set close with scoped ownership. The identifier is bound as a parameter, and a missing row fails explicitly. The driver call remains blocking while it runs; production code still needs transaction policy, pool limits, a query timeout and a documented cancellation behavior.

Compiled and executed in an isolated fixture.

Follow-up: How do you know whether a library call is truly non-blocking?

Inspect its API contract and implementation documentation, then observe whether the call returns while I/O is pending and how cancellation propagates. A suspend wrapper alone is not evidence: it may simply execute a blocking call on another thread.

Read the source: Coroutine context and dispatchers · Kotlin coroutine dispatchers and threads

↑ Back to questions

Question 7 of 10

How do Flow, SharedFlow and StateFlow differ?

The Flow interface alone does not tell you whether a stream is cold or hot. The flow { ... } builder creates a cold flow whose upstream block runs for each collection; SharedFlow and StateFlow are hot shared-stream types. Choose based on whether the contract is a per-collector computation, an event broadcast or observable current state.

The flow { ... } builder returns a cold flow: intermediate operators set up a pipeline, and collection starts the producer. Two collectors can therefore trigger two upstream requests unless the flow is explicitly shared. This property belongs to that builder-produced flow, not to every value typed as Flow; the interface deliberately does not encode whether an implementation is cold or hot. shareIn and stateIn convert an upstream flow into a shared hot stream within a supplied scope. That scope’s lifecycle, start policy and replay behavior become part of the API contract.

A SharedFlow can broadcast events to active collectors and optionally replay values to later collectors. A StateFlow always has a current value and is suitable for state snapshots such as loading/success/error. It conflates updates for slow collectors, so it is not a durable queue of every event. Neither primitive persists data or guarantees delivery across process death. For one-consumer work distribution, a channel has different semantics; do not substitute these types based only on syntax similarity.

Kotlin/JVM with kotlinx.coroutines · Worked example
val requests = flow {
    println("fetch")
    emit(api.load())
}

requests.collect { renderA(it) } // fetch runs
requests.collect { renderB(it) } // fetch runs again

Each collection starts this flow { ... } builder’s cold upstream again, so the request is repeated. The Flow interface itself does not promise that every implementation behaves this way. If the intended contract is one shared fetch for several UI collectors, share it in a lifecycle-owned scope and specify replay/start behavior. That changes its lifetime and caching semantics, so it should be an explicit decision.

Compiled and executed in an isolated fixture.

Follow-up: Where should a stateIn scope live for screen state?

At the owner whose lifetime should control the upstream work, such as a view model or application service depending on the data. Tie cancellation to that owner, and choose a sharing start policy that matches whether the upstream should stop when there are no subscribers.

Read the source: Kotlin Flow overview · Asynchronous flow and shared state · SharedFlow API · StateFlow API · Flow interface and cold/hot semantics, kotlinx.coroutines API

↑ Back to questions

Question 8 of 10

Does using coroutines remove data races?

No. Coroutines can interleave on one thread or run in parallel on several threads, so mutable state shared by concurrent coroutines still needs an ownership or synchronization rule. Confinement, immutable messages, a Mutex, atomic operations or an actor-like channel can protect an invariant; choose the smallest mechanism that matches the state and access pattern. Validate the operation’s domain before entering the critical section; a lock protects a transition but does not define which inputs are valid.

A coroutine is a suspendable computation, not a guarantee of single-threaded execution. A dispatcher may resume it on different threads, and multiple coroutines can update the same object concurrently. Even on one thread, suspension can let another coroutine observe or modify state between two steps. A compound operation such as “check balance, then subtract” must be atomic as a whole if another operation can interleave.

Thread confinement assigns mutable state to one dispatcher or actor and routes messages to its owner. A Mutex protects a critical section across coroutine suspension points, but holding it during slow external I/O can serialize unrelated work and create latency. Atomic values protect only their supported operation, not a multi-field invariant. StateFlow.update can perform an atomic state transformation, but it does not make mutation of objects inside the state safe. Prefer immutable snapshots and explicit transitions where possible.

Kotlin/JVM with kotlinx.coroutines · Worked example
val mutex = Mutex()
val balance = MutableStateFlow(100)

suspend fun withdraw(amount: Int): Boolean {
    require(amount > 0) { "amount must be positive" }
    return mutex.withLock {
        val current = balance.value
        if (current < amount) return@withLock false
        balance.value = current - amount
        true
    }
}

require rejects zero and negative amounts before acquiring the mutex, so an invalid negative withdrawal cannot increase the balance. For a positive Int amount, the guarded current >= amount check ensures subtraction stays nonnegative and cannot overflow; the check and update remain one protected transition. This safety assumes every writer follows the same mutex/ownership protocol and the balance is not externally mutated. This serializes callers through this critical section; keep it small and do not hold it during network I/O. A StateFlow update lambda is another option for a single immutable value, but its transform can be retried and must not perform side effects.

Compiled and executed in an isolated fixture.

Follow-up: How would you test a race in a balance transition?

Start two withdrawals at a controlled barrier against a known balance, await both, then assert the invariant: the balance never goes negative and the total accepted amount does not exceed the initial amount. Use bounded waits and deterministic coordination rather than sleeping.

Read the source: Shared mutable state and concurrency · StateFlow and MutableStateFlow API

↑ Back to questions

Question 9 of 10

What changes when Kotlin APIs are called from Java?

Kotlin's source-level conveniences do not all appear as the same Java API. Default arguments are implemented through generated methods and are not directly callable as omitted arguments from ordinary Java source. Nullability annotations, collection mutability mappings, property accessors, checked-exception expectations and JVM signature clashes all affect the boundary. Design and inspect the bytecode-facing API when Java callers are part of the contract.

A Kotlin function fun search(term: String, limit: Int = 20) requires Java to call the generated JVM method with both parameters unless the API provides overloads, commonly through @JvmOverloads, or a deliberate Java-facing factory. @JvmOverloads generates overloads for trailing default parameters; it does not solve every overload ambiguity or binary compatibility concern. Kotlin properties compile to accessor methods, and names can clash after type erasure or getter generation even when Kotlin source signatures look distinct.

Kotlin's read-only List<T> is a compile-time interface promise, not an immutable JVM collection. Java may still hold a mutable reference to the same object and change it. Similarly, Java callers can pass null unless the boundary is annotated and enforced. Review the public JVM signatures with a small Java compilation fixture or API inspection tool. Keep domain validation in the Kotlin boundary rather than assuming every caller is compiled by Kotlin.

Kotlin and Java interop sketch · Worked example
@JvmOverloads
fun search(term: String, limit: Int = 20): List<Result> =
    listOf(Result(term)).take(limit)

The overload annotation asks the compiler to emit a Java-callable overload that omits the trailing default parameter. The fixture compiles a separate Java caller against both generated signatures. Without the annotation, Java source generally calls the full JVM signature; a production API must also define validation, nullability annotations and collection ownership.

Compiled and executed in an isolated fixture.

Follow-up: How would you verify a Kotlin library's Java API?

Compile a small Java consumer against the published artifact and inspect generated signatures with javap or the IDE's bytecode view. Include null and collection boundary cases, and keep the fixture against the artifact rather than only Kotlin source-level tests.

Read the source: Calling Kotlin from Java · Calling Java from Kotlin: mapped types and nullability

↑ Back to questions

Question 10 of 10

How do you test coroutine timing and cancellation deterministically?

Use the coroutine test library's test scheduler and runTest to control virtual time for coroutine delays and to await child work. Replace external clocks and services with fakes when they are outside the scheduler. Test the observable cancellation and cleanup contract, not a particular thread name or a real-time sleep.

Coroutine tests can become slow and flaky when they wait on real delays or let child jobs escape the test scope. runTest provides a controlled test environment for coroutines using its test dispatcher and scheduler. Advancing virtual time can trigger delayed work without waiting in wall-clock time, while runCurrent and advanceUntilIdle help make scheduled tasks explicit. Tests should use one scheduler when dispatchers share a timeline; otherwise the test may not control all work as expected.

Virtual time applies only to code using the test scheduler. A blocking JDBC call, Thread.sleep, real HTTP request or dispatcher-backed operation may still use wall-clock time and external resources. Inject a fake or a test dispatcher at the boundary, and make cleanup observable with a tracked resource or job. For cancellation, cancel the parent, advance or yield as required, and assert that child work stopped and cleanup ran. Keep deadlines on all waits so an uncooperative child fails clearly instead of hanging the suite.

Kotlin/JVM with kotlinx-coroutines-test · Worked example
@Test
fun delayedResultAppearsAtDeadline() = runTest {
    val values = mutableListOf<String>()
    launch {
        delay(1_000)
        values += "ready"
    }
    runCurrent()
    assertEquals(emptyList(), values)
    advanceTimeBy(999)
    runCurrent()
    assertEquals(emptyList(), values)
    advanceTimeBy(1)
    runCurrent()
    assertEquals(listOf("ready"), values)
}

The test scheduler advances the coroutine delay without a one-second wall-clock sleep. The intermediate assertion proves the value is still absent at 999 ms; advancing the final millisecond and calling runCurrent() executes the task scheduled at 1,000 ms. The fixture uses kotlinx-coroutines-test 1.10.2 and checks both sides of that boundary.

Compiled and executed in an isolated fixture.

Follow-up: What if the coroutine calls a Java API that blocks?

Inject a boundary that can be controlled by the test, or use a fake that models the relevant completion and failure timing. Do not assume advancing coroutine virtual time will interrupt a blocking library call; test that adapter's timeout and cancellation behavior separately.

Read the source: kotlinx-coroutines-test: runTest and virtual time · kotlinx-coroutines-test API

↑ Back to questions

Salary context

Advertised salaries for these skills

These are Hacker News job posts that mention these technologies, not employee earnings or a measure of what a skill adds to pay. The amounts summarize annualized advertised minimums in approximate USD. Groups can overlap and differ in roles, locations and dates.

Kotlin posts

No unambiguous salary cohort is available for this topic.

↑ Back to questions

Keep exploring