Kotlin posts
No unambiguous salary cohort is available for this topic.
Interview questions · 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.
Question 1 of 10
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.
// 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.
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
Question 2 of 10
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.
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 listcopy() 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.
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
Question 3 of 10
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.
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 checkSource 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.
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
Question 4 of 10
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.
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.
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
Question 5 of 10
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.
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.
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
Question 6 of 10
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.
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.
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
Question 7 of 10
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.
val requests = flow {
println("fetch")
emit(api.load())
}
requests.collect { renderA(it) } // fetch runs
requests.collect { renderB(it) } // fetch runs againEach 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.
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
Question 9 of 10
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.
@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.
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
Question 10 of 10
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.
@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.
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
Salary context
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.
No unambiguous salary cohort is available for this topic.