Skip to content
Mobile & design

11 Android Kotlin and Types Interview Questions and Answers

This focused guide turns RecallDeck’s curated Android Kotlin and Types material into 11 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

12 min read11 detailed answersReviewed Aug 24, 2026
What to remember

Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.

Question set

11 detailed answers

01

How does val differ from var, and does val make an object immutable?

Short answer: var is a reassignable reference, val is read-only: you cannot reassign it. But val freezes only the reference, not the object's contents: val list = mutableListOf<Int>() still accepts list.add(1). Real immutability is a read-only type plus val.

In depth:

val a = mutableListOf(1, 2)
a.add(3)          // ✅ same reference, contents change
// a = mutableListOf() // ❌ val cannot be reassigned

var n = 0
n = 1             // ✅ var is reassignable

val now: Long get() = System.currentTimeMillis() // val with a getter — not a constant!
const val API = "v1"  // const — compile time, primitives and String only
  1. val ≠ immutable — it only guarantees a stable reference; mutability depends on the type (List vs MutableList).
  2. val with a custom getter — is computed on every access, and its value can differ from call to call.
  3. const val — is inlined by the compiler at the use site; allowed only at top level or in an object/companion, with a primitive or String value.

⚠️ Common mistake: treating val as a synonym for immutable. val is about the reference, not the depth of the object.

02

What does a data class generate, and what are its pitfalls?

Short answer: The compiler generates equals()/hashCode(), toString(), copy(), and componentN() — but only from the primary-constructor properties. Properties declared in the class body are excluded, and arrays are compared by reference.

In depth:

data class User(val id: Int, val name: String) {
    var lastSeen: Long = 0   // ⚠️ NOT part of equals/hashCode/toString/copy
}

val a = User(1, "Ann").apply { lastSeen = 100 }
val b = User(1, "Ann").apply { lastSeen = 999 }
a == b            // true! lastSeen is ignored

data class Packet(val bytes: ByteArray)  // ⚠️ equals on the array reference,
                                          // needs a manual contentEquals
  1. Generated from the constructor — only val/var in the primary constructor; body fields silently drop out of equals.
  2. copy() — a shallow copy: nested objects are shared. copy() also ignores body fields.
  3. Arraysequals/hashCode use the reference, not the contents: override them manually with contentEquals.
  4. Inheritance — a data class cannot be open or extend another data class; the equals contract would break.

⚠️ Common mistake: putting mutable state in the data-class body and being surprised that two "different" objects are equal — and collapse into one inside a HashSet.

03

Operators ?., ?:, !! and platform types from Java — how does null-safety work?

Short answer: ?. is a safe call (returns null instead of an NPE), ?: (Elvis) supplies a default, !! asserts "I guarantee non-null" and throws an NPE if you're wrong. The hole in the system is platform types: values from Java (String!) are not null-checked by the compiler.

In depth:

val len: Int? = user?.name?.length      // the chain short-circuits on the first null
val safe: Int = user?.name?.length ?: 0 // Elvis gives a default
val forced: Int = user!!.name!!.length  // ⚠️ two potential NPEs

// Java method: String getName() -> in Kotlin this is String! (a platform type)
val n = javaObj.name        // type String! — no checks
val ok: String = javaObj.name  // NPE right here if it returned null
Operator If null When
?. returns null safe navigation
?: substitutes the right side default / early exit
!! throws an NPE only with an ironclad guarantee
  1. Platform types (T!) — Kotlin doesn't know nullability from Java, so it trusts you; annotate Java code with @Nullable/@NonNull.
  2. Elvis + return/throw — the idiom val x = maybe() ?: return for an early exit.

⚠️ Common mistake: scattering !! to silence the compiler. Every !! is a deferred NPE; usually ?. with ?: is the right tool.

04

When do you choose a sealed class/interface over an enum?

Short answer: An enum is a fixed set of same-shaped singleton constants. A sealed type is a closed hierarchy where each subtype carries its own data and structure. Both give an exhaustive when with no else, but an enum is one instance per constant, while you can create as many sealed subtypes as you want with different fields.

In depth:

enum class Status { LOADING, SUCCESS, ERROR }  // constants with no data

sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<Item>) : UiState  // carries data
    data class Error(val cause: Throwable) : UiState      // its own fields
}

fun render(s: UiState) = when (s) {   // no else needed — the compiler knows them all
    UiState.Loading   -> showSpinner()
    is UiState.Success -> showList(s.items)
    is UiState.Error   -> showError(s.cause)
}
enum sealed
Per-variant data same fields each its own
Instance count one per constant as many as you like (except object)
Hierarchy flat a tree of any depth
when without else yes yes (when branches are known)
  1. enum — when variants are interchangeable in shape: weekdays, statuses with no payload.
  2. sealed — modeling screen/result states where branches need different data.

⚠️ Common mistake: cramming heterogeneous data into an enum via just-in-case nullable fields. That is exactly where a sealed type belongs.

05

let, run, with, apply, also — how do you pick the right scope function?

Short answer: Choose along two axes: how you reference the object (receiver this or argument it) and what it returns (the object itself or the lambda result). apply/also return the object — for configuration; let/run/with return the lambda result — for transformation.

In depth:

Function Object as Returns Typical use
let it lambda result null-safe transform x?.let { }
run this lambda result compute + config with member access
with this lambda result a group of calls on one object
apply this the object setup: View().apply { }
also it the object side effect: logging, validation
val name = user?.let { it.first + " " + it.last } // transform under a null-check
val view = TextView(ctx).apply {                  // config, returns the view
    text = "Hi"; textSize = 16f
}
repo.save(item).also { log("saved id=${it.id}") } // pass-through side effect
  1. it vs thisit reads better when the object is passed onward as an argument; this when you call its members.
  2. return object vs resultapply/also slot into a chain without breaking it; let/run change the type.

⚠️ Common mistake: nested lets with shadowed it — you lose track of which object it refers to. Give the lambda an explicit parameter name.

06

How does lateinit differ from by lazy, and when do you use each?

Short answer: lateinit var is a mutable property you initialize later by hand (DI, onCreate); accessing it before assignment throws UninitializedPropertyAccessException. by lazy is a val computed once on first access and cached thereafter. lateinit is "I'll initialize it myself"; lazy is "it computes itself on first read".

In depth:

lateinit var by lazy
Mutability var val
Who initializes you, manually the block on first access
Primitives not allowed (objects only) allowed
Nullable not allowed allowed
Check ::x.isInitialized always initialized after 1st read
@Inject lateinit var repo: Repo        // DI assigns it before use

val db by lazy {                       // heavy object — on demand
    Room.databaseBuilder(ctx, Db::class.java, "app").build()
}

// lazy's thread-safety is configurable:
val cfg by lazy(LazyThreadSafetyMode.NONE) { parse() } // no synchronization
  1. lateinit — for a var that the lifecycle or DI is guaranteed to fill before any read; check with isInitialized.
  2. lazy — for an expensive val that might never be needed; SYNCHRONIZED by default (thread-safe).

⚠️ Common mistake: lateinit on Int/Boolean — it won't compile, primitives are unsupported. And accessing lateinit before init is not null but an exception.

07

How are extension-function calls resolved, and why isn't it polymorphism?

Short answer: Extension functions are resolved statically — by the declared type of the variable, not the object's actual runtime type. The compiler turns them into static methods that take the receiver as the first argument; there is no virtual dispatch like a regular method has.

In depth:

open class A
class B : A()

fun A.who() = "A"
fun B.who() = "B"

val x: A = B()
println(x.who())   // "A" — chosen by the variable's type (A), not by B!

// the compiler effectively generates:
// static String who(A receiver) { return "A"; }
  1. Static resolution — which extension is called is decided by the declared (static) type of the expression, not the runtime class.
  2. Does not override a member — if the class has a method with the same signature, the member always wins and the extension is ignored.
  3. Under the hood — a plain static method with a receiver parameter; hence no access to private members and no virtuality.

⚠️ Common mistake: expecting polymorphism from extensions — "I'll override the behavior for the subclass." Runtime dispatch needs real open/override methods.

08

How does == differ from ===, and why does === on Int? sometimes lie?

Short answer: == is structural equality, compiled to a null-safe equals() call. === is referential equality (the same object in memory). On boxed numbers === gives a treacherous result: the JVM caches Integer in the range −128..127, so equal Int? values of 100 may be ===, while values of 1000 are not.

In depth:

val a: Int? = 127
val b: Int? = 127
a == b     // true — value comparison
a === b    // true — both come from the Integer cache (−128..127)

val c: Int? = 1000
val d: Int? = 1000
c == d     // true
c === d    // false! outside the cache — different objects

"ab" == "ab"     // true (equals)
"ab" === "ab"    // depends on interning — don't rely on it
  1. == → equalsa == b is a?.equals(b) ?: (b === null); for your own types override equals/hashCode as a pair.
  2. === → identity — the very same object; for non-boxed primitives it degenerates into a value comparison.
  3. equals/hashCode contract — equal objects must have equal hashCode, or HashMap/HashSet break.

⚠️ Common mistake: checking Int?/Integer with ===. Because of the −128..127 cache the result depends on the value — use ==.

09

Variance in generics: what do out, in, and star-projection mean?

Short answer: Generics in Kotlin are invariant by default: List<String> is not a subtype of List<Any>. out T (covariance) means the type only "produces" T (return position), so Producer<String> is a subtype of Producer<Any>. in T (contravariance) means it only "consumes" T (argument position). Mnemonic — PECS: Producer-out, Consumer-in.

In depth:

interface Producer<out T> { fun get(): T }        // T is output-only
interface Consumer<in T>  { fun put(item: T) }     // T is input-only

val strs: Producer<String> = ...
val anys: Producer<Any> = strs   // ✅ covariant: String -> Any

val anyC: Consumer<Any> = ...
val strC: Consumer<String> = anyC // ✅ contravariant: Any -> String

fun printAll(items: List<*>) {    // star-projection: type unknown
    items.forEach(::println)      // read as Any?, cannot write
}
Modifier Role of T Subtyping
out T producer (output) C<Sub> <: C<Super>
in T consumer (input) C<Super> <: C<Sub>
<*> unknown read the out bound, cannot write
  1. Declaration-siteout/in on the type declaration apply to every use (unlike Java's use-site wildcards).
  2. Star-projection <*> — "some specific but unknown type": safe to read as the upper bound, forbidden to write.

⚠️ Common mistake: trying to put an out parameter in an input position (a function argument) — the compiler forbids it: it would break type safety.

10

What does reified do, and why does it work only on inline functions?

Short answer: On the JVM generics are erased (type erasure): at runtime T becomes Object, so you can't write T::class or x is T. An inline function copies its body into the call site, where the concrete type is known at compile time; reified lets you use that type directly in the body. Without inline there is nowhere to substitute it — hence the restriction.

In depth:

// ❌ not allowed: T is erased, is T is impossible
fun <T> parseBad(json: String): T = gson.fromJson(json, T::class.java)

// ✅ reified: the type is known at the call site
inline fun <reified T> parse(json: String): T =
    gson.fromJson(json, T::class.java)

val user = parse<User>(json)   // the compiler inserts User.class here

// a common Android helper:
inline fun <reified T> Context.startActivity() =
    startActivity(Intent(this, T::class.java))
  1. Type erasure — a plain <T> is indistinguishable from Object at runtime; type checks and reflection on T are unavailable.
  2. inline puts the body at the call site — there the concrete type argument is known to the compiler, which physically substitutes User.class.
  3. What reified allowsT::class, x is T, x as T, calling another reified function.

⚠️ Common mistake: expecting reified from a regular (non-inline) function. The body isn't copied — there is nowhere to recover the type, so the compiler refuses.

11

Why do higher-order functions need inline, noinline, and crossinline?

Short answer: Every non-inline lambda is a Function object allocation. inline embeds the function's body and its lambdas at the call site, removing the allocation and enabling non-local return. noinline excludes a specific lambda from inlining (so it can be stored/passed on). crossinline forbids non-local return in a lambda when it's invoked from another context.

In depth:

inline fun measure(block: () -> Unit) {   // block is inlined, no lambda object
    val t = System.nanoTime(); block(); log(System.nanoTime() - t)
}

inline fun run2(
    a: () -> Unit,
    noinline b: () -> Unit    // we store b in a field -> it cannot be inlined
) { a(); store(b) }

inline fun forEachSafe(block: crossinline () -> Unit) {
    val r = Runnable { block() }  // block is called from another context ->
    r.run()                        // crossinline forbids returning from the caller here
}
Modifier What it does When
inline inline the function and lambdas hot HOF, remove allocations
noinline do NOT inline this lambda you need to store/pass the lambda
crossinline forbid non-local return the lambda is called from a nested context
  1. inline's win — no lambda object and no extra call; a return from an inline lambda to the outside (non-local) is supported.
  2. The cost — the body is copied into every call: inlining large functions bloats bytecode. For small HOFs it pays off.
  3. noinline/crossinline — targeted exceptions to a blanket inline, not standalone modes.

⚠️ Common mistake: slapping inline on everything "for speed." For functions without lambda parameters there's almost no gain and the code bloats — the compiler even warns you.

Source notes

References and review policy

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS