📗 Core Concept · ~40 min read · Intermediate → Advanced

Kotlin: Interview Deep Dive

The Kotlin topics that separate a confident Android candidate from a shaky one — null safety, data and sealed classes, scope functions, lambdas, delegation, generics, sequences, inheritance, and exceptions, all explained with the depth interviewers actually probe for.

Null safety

Kotlin’s type system bakes nullability into the type itself. String and String? are two different types at compile time. The compiler will refuse to let you call a method on a String? without first proving the value is non-null. This single decision eliminates an entire class of NullPointerExceptions that plague Java codebases.

The operators you need to know cold:

OperatorNameWhat it doesThrows?
?.Safe callReturns null if receiver is null, otherwise calls the memberNever
?:ElvisReturns right side if left is nullNever
!!Non-null assertionUnwraps nullable or throws KotlinNullPointerExceptionYes
as?Safe castReturns null if cast fails instead of throwing ClassCastExceptionNever
letNull-safe blockExecutes block only if receiver is non-null; it = non-null valueNever
📋 Kotlin null safety — which operator to reach for
val x: String? What do you want to do? access member x?.property null if x is null need fallback x ?: defaultValue Elvis operator run a block x?.let { it } block only if non-null safe cast view as? TextView null if wrong type x!! — throws if null Only when you KNOW it’s non-null. Avoid.
Null safety — all operators in context
// Safe call — returns null if user is null, no crash val city = user?.address?.city // Elvis — provide a fallback when null val displayName = user?.name ?: "Guest" // Chain safe call + Elvis — very common pattern val length = name?.length ?: 0 // let — execute a block only when non-null user?.let { u -> showProfile(u) // u is non-null String inside the block } // Safe cast — null instead of ClassCastException val tv = view as? TextView // null if view is not a TextView // !! — the "I know what I'm doing" operator. Avoid unless truly certain. val nonNull = maybeNull!! // KotlinNullPointerException if null // lateinit — non-null var initialised after construction (e.g. DI, Android lifecycle) lateinit var binding: ActivityMainBinding // Check before use: if (::binding.isInitialized) { ... } // Platform types — Java interop returns T! (unknown nullability) // Assign to a typed variable immediately to get compile-time safety val name: String? = intent.getStringExtra("key") // explicitly nullable
⚠️ !! is a code smell

Every !! in your codebase is a bet that this value will never be null at runtime. Interviewers notice overuse of !! — it suggests you’re writing Kotlin like Java. The correct response to a nullable value is to handle the null case explicitly with ?., ?:, or let. Reserve !! for places where a null is genuinely a programmer error that should crash fast.

Data classes

A data class is a class whose primary purpose is to hold data. Kotlin generates five things for you automatically based on the properties declared in the primary constructor: equals(), hashCode(), toString(), copy(), and componentN() functions for destructuring. This covers probably 80% of the boilerplate you write in Java model classes.

Data class — what the compiler generates and how to use it
data class Product(val id: Int, val name: String, val price: Double) // ── equals / hashCode ──────────────────────────────────────────────────── val p1 = Product(1, "Shirt", 499.0) val p2 = Product(1, "Shirt", 499.0) println(p1 == p2) // true — structural equality, not reference println(p1 === p2) // false — different objects in memory // ── copy — immutable update pattern ────────────────────────────────────── val discounted = p1.copy(price = 399.0) // new object, only price changed // ── Destructuring via componentN ────────────────────────────────────────── val (id, name, price) = p1 // calls component1(), component2(), component3() products.forEach { (id, name, price) -> println("$id: $name = ₹$price") } // ── toString ───────────────────────────────────────────────────────────── println(p1) // Product(id=1, name=Shirt, price=499.0) // ── Data class rules ────────────────────────────────────────────────────── // 1. Properties in PRIMARY constructor are included in equals/hashCode/copy // 2. Properties in the body are NOT included — common gotcha! data class User(val id: Int, val name: String) { var sessionToken: String? = null // NOT part of equals/hashCode! } // 3. Cannot be open/abstract — data classes are implicitly final // 4. Must have at least one val/var in the primary constructor
⚠️ The body property gotcha

Properties declared in the class body are invisible to equals(), hashCode(), and copy(). Two User instances with different sessionTokens will still be == if their id and name match. This trips people up when using data classes as map keys or in sets.

Sealed classes & sealed interfaces

A sealed class restricts class hierarchies: all direct subclasses must be declared in the same package and compilation unit. This gives the compiler a closed world — it knows every possible subtype at compile time. The payoff is exhaustive when expressions: if you handle every subclass, no else branch is required, and the compiler errors if you add a new subclass without updating every when.

This makes sealed classes the perfect type for modelling states and results — the two most common use cases in Android (UI state machines and network response wrappers).

📋 Sealed class hierarchy — compiler knows every subtype
sealed class UiState<out T> object Loading No data needed. Singleton subclass. data class Success(val data: T) Carries typed payload. data class Error(val msg: String) Carries error info. object Empty No results, no error. when (state) { is UiState.Loading → showSpinner()    is UiState.Success → showData(state.data) is UiState.Error → showError(state.msg)    is UiState.Empty → showEmptyView() } ← no else needed!
Sealed class — UiState pattern + sealed interface
// Sealed class — all subclasses in same file/package sealed class UiState<out T> { object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>() object Empty : UiState<Nothing>() } // Exhaustive when — compiler error if a subclass is added but not handled when (state) { is UiState.Loading -> showSpinner() is UiState.Success -> showData(state.data) // smart cast: state is Success<T> is UiState.Error -> showError(state.message) is UiState.Empty -> showEmptyView() // No else branch needed — the compiler knows these are all cases } // Sealed interface (Kotlin 1.5+) — allows multi-inheritance of implementations sealed interface NetworkResult<out T> data class NetworkSuccess<T>(val value: T) : NetworkResult<T> data class NetworkError(val code: Int) : NetworkResult<Nothing> // Sealed vs Enum — know the difference cold // Enum: each constant is a singleton, same type, no per-instance data enum class Direction { NORTH, SOUTH, EAST, WEST } // Sealed: each subclass can be a different type with different properties // Use sealed when subclasses need to carry different data
FeatureSealed classEnum class
Per-instance dataYes — each subclass has its own fieldsNo — all constants share the same shape
Subclass typeEach subclass is a distinct typeEvery constant is the same enum type
InstantiationCan have multiple instances (data class) or singleton (object)Each constant is a singleton
Exhaustive whenYes (compiler enforced)Yes (compiler enforced)
Typical useUI state, network results, algebraic typesFixed set of constants: directions, days, status codes

Scope functions

Scope functions (let, run, with, apply, also) are higher-order functions that execute a block in the context of an object. They all do roughly the same thing — run a block — but differ on two axes: how the context object is referenced inside the block (this vs it), and what the function returns (the lambda result or the context object itself). Getting these two axes right is the key to using them idiomatically.

📋 Scope functions — two axes, five functions
Function Context obj Returns Idiomatic use let it (lambda param) can rename it Lambda result Null-safe blocks: user?.let { send(it) } Transforming a value in a chain run this (receiver) Lambda result Computing a value using object members: val label = user.run { "$firstName $lastName" } with this (receiver) Lambda result Not an extension — call as with(obj) { ... } Grouping multiple ops on a non-null object apply this (receiver) Context object (returns itself) Object configuration/builder pattern: TextView(ctx).apply { text="Hi"; textSize=16f } also it (lambda param) Context object (returns itself) Side effects without breaking chains: fetchUser().also { log(it) }.let { render(it) }
Scope functions — the right tool for each job
// apply — configure a new object, returns the object itself (this = receiver) val dialog = AlertDialog.Builder(context).apply { setTitle("Confirm") setMessage("Are you sure?") setPositiveButton("Yes") { _, _ -> onConfirm() } }.create() // let — null-safe execute + transform (it = parameter) val upper = name?.let { it.uppercase() } // null if name is null // run — compute a result using the object's members (this = receiver) val fullName = user.run { "$firstName $lastName" } // with — group operations, not an extension function val summary = with(order) { "Order #$id: $itemCount items, total ₹$total, status: $status" } // also — side effects (logging, validation) without interrupting a chain val users = fetchUsers() .also { Log.d("TAG", "Fetched ${it.size} users") } .filter { it.isActive } // Memory hook: apply/also return the object (good for builders + side effects) // let/run/with return the lambda result (good for transformations) // apply/run use "this"; let/also use "it"

Lambdas & higher-order functions

A higher-order function is any function that takes a function as a parameter or returns one. This is the foundation of Kotlin’s functional style — map, filter, let, apply, and every coroutine builder are all higher-order functions. A lambda is an anonymous function literal that can be passed as a value.

Lambdas & higher-order functions — syntax and function types
// Function type: (InputType) -> ReturnType val double: (Int) -> Int = { x -> x * 2 } val greet: (String) -> Unit = { name -> println("Hello, $name") } // Trailing lambda syntax — if the last param is a function, move it outside () listOf(1, 2, 3).forEach { println(it) } // it = implicit single parameter listOf(1, 2, 3).forEach { num -> println(num) } // explicit name // Returning from a lambda — use a label, not "return" (which exits the enclosing fn) listOf(1, 2, 3).forEach forEach@{ if (it == 2) return@forEach // returns from lambda, continues outer loop println(it) } // Writing a higher-order function fun <T> List<T>.findFirst(predicate: (T) -> Boolean): T? { for (item in this) if (predicate(item)) return item return null } val first = products.findFirst { it.price < 500 } // Function references — :: syntax avoids creating a lambda wrapper val names = users.map(User::name) // member reference listOf("a", "b").forEach(::println) // top-level function reference // Nullable function type — the () -> Unit can itself be null fun doWork(onComplete: (() -> Unit)? = null) { // work... onComplete?.invoke() // safe call on the function itself }

Inline functions & reified generics

Every time you pass a lambda to a function, Kotlin creates an anonymous class object and a method call at runtime — an allocation you pay for on every invocation. For frequently-called higher-order functions this overhead adds up. The inline keyword tells the compiler to copy-paste the function body (and the lambda body) directly at the call site instead of creating a function call. No object allocation, no virtual dispatch.

reified unlocks a second superpower of inlining: access to the actual type argument at runtime. Normally, generics are erased at runtime (type erasure from the JVM). But because an inline reified function is copy-pasted at the call site, the compiler knows the concrete type — it substitutes T::class.java with the real class wherever it appears in the function body.

inline / noinline / crossinline / reified
// inline — compiler pastes the body at call site, no lambda object created inline fun measureMs(block: () -> Unit): Long { val start = System.currentTimeMillis() block() return System.currentTimeMillis() - start } // At call site, this becomes: val start = ...; doWork(); return ... — no allocation val ms = measureMs { doWork() } // reified — use T as a real class inside the function body inline fun <reified T : ViewModel> Fragment.viewModel(): T = ViewModelProvider(this)[T::class.java] // Without reified you'd need to pass the class explicitly every time: // fun <T : ViewModel> Fragment.viewModel(clazz: Class<T>): T = ViewModelProvider(this)[clazz] inline fun <reified T> String.fromJson(): T = Gson().fromJson(this, T::class.java) val product: Product = jsonString.fromJson() // T inferred as Product // noinline — opt a specific lambda param OUT of inlining // needed when you store the lambda or pass it to a non-inline function inline fun runWithCallback(action: () -> Unit, noinline callback: () -> Unit) { action() handler.post(callback) // handler.post needs a Runnable object — can't inline } // crossinline — prevents non-local returns inside a lambda that will run in // a different execution context (e.g. passed to Runnable) inline fun runOnMain(crossinline block: () -> Unit) { handler.post { block() } // block can't do `return` — that would escape runOnMain's caller }
💡 When to use inline

Use inline for utility functions that accept lambdas and are called frequently — measurement helpers, retry wrappers, DSL builders, scope-function-style APIs. Don’t inline large functions; the body gets copy-pasted everywhere which inflates bytecode. The compiler will warn you if an inline function has no lambda parameters (there’s nothing to inline away).

Extension functions & properties

Extension functions let you add new functions to existing classes without inheriting from them or wrapping them. Under the hood, the compiler generates a static method that takes the receiver as its first parameter — there is no actual modification of the class. This has two important implications: extension functions cannot access private or protected members, and if an extension function has the same signature as a member function, the member always wins.

Extension functions — practical patterns
// Basic extension function fun String.isValidEmail(): Boolean = isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() fun View.visible() { visibility = View.VISIBLE } fun View.gone() { visibility = View.GONE } fun View.invisible() { visibility = View.INVISIBLE } // Extension property (computed, no backing field) val Int.dp: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() val padding = 16.dp // 16dp in pixels // Extension on nullable receiver — can be called on null without crashing fun String?.orDash(): String = this ?: "—" val display = user?.name.orDash() // works even if user or name is null // Extension on a companion object — for factory-style helpers fun Intent.Companion.openBrowser(url: String): Intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) // The member-wins rule: if String already has contains(), your extension won't shadow it fun String.contains(s: String): Boolean = false // never called — member wins "hello".contains("ell") // returns true — the standard member is used

Object declarations & companion objects

Kotlin’s object keyword creates a thread-safe singleton without any of the Java double-checked locking boilerplate. The instance is created lazily on first access, guaranteed by the class loader. A companion object is an object scoped to a class — it’s the Kotlin equivalent of static members, but richer because it’s a real object that can implement interfaces and have extension functions defined on it.

Object & companion object — singletons, factories, constants
// Object declaration — thread-safe singleton, initialised lazily object AppConfig { const val BASE_URL = "https://api.example.com" val retrofit by lazy { /* build Retrofit */ } fun isDebug() = BuildConfig.DEBUG } AppConfig.BASE_URL AppConfig.isDebug() // Companion object — belongs to the class, not the instance class User private constructor(val name: String, val email: String) { companion object { // Factory method — can validate before constructing fun create(name: String, email: String): User? { if (name.isBlank() || !email.isValidEmail()) return null return User(name, email) } const val MAX_NAME_LENGTH = 50 } } val user = User.create("Alice", "alice@example.com") // Anonymous object — one-off implementation without a class name val listener = object : View.OnClickListener { override fun onClick(v: View) { handleClick() } } // Note: anonymous objects are NOT singletons — new instance each time they appear // @JvmStatic — makes companion member callable as a Java static companion object { @JvmStatic fun newInstance(): MyFragment = MyFragment() } // Java: MyFragment.newInstance() — instead of MyFragment.Companion.newInstance()

Delegation

Kotlin has first-class support for the delegation pattern via the by keyword. It shows up in three places: property delegation (by lazy, by Delegates.observable), interface delegation (forwarding method calls to another object), and by viewModels() / by activityViewModels() in Android — which are all property delegates under the hood.

📋 Delegation — three flavours of the by keyword
by lazy { } Initialised on first access. Result cached forever after. Thread-safe by default (SynchronizedLazyImpl). val db by lazy { buildDb() } by Delegates.observable Callback fires AFTER every assignment with old + new value. by Delegates.vetoable Callback fires BEFORE. Return false to reject the new value. Interface delegation class A(b: B) : I by b A forwards all I methods to b automatically. Override only the methods you want to change. Custom property delegate — implement ReadWriteProperty<Any?, T> operator fun getValue(thisRef: Any?, prop: KProperty<*>): T operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) Used by: by viewModels(), by sharedPreferences(), by savedStateHandle()
Delegation — lazy, observable, vetoable, interface, custom
// by lazy — compute once, cache forever. Thread-safe by default. val prefs by lazy { context.getSharedPreferences("app", MODE_PRIVATE) } // LazyThreadSafetyMode.NONE — slightly faster, single-thread only val cache by lazy(LazyThreadSafetyMode.NONE) { HashMap<String, Any>() } // by Delegates.observable — watch a property for changes var query: String by Delegates.observable("") { _, old, new -> if (old != new) triggerSearch(new) } // by Delegates.vetoable — reject invalid assignments var age: Int by Delegates.vetoable(0) { _, _, new -> new >= 0 } age = -5 // rejected — age stays 0 // Interface delegation — forward all MutableList methods to inner, override add class LoggingList<T>(private val inner: MutableList<T> = mutableListOf()) : MutableList<T> by inner { override fun add(element: T): Boolean { Log.d("List", "Adding $element") return inner.add(element) } } // Custom delegate — SharedPreferences-backed property class PrefDelegate(private val prefs: SharedPreferences, private val key: String) : ReadWriteProperty<Any?, String> { override fun getValue(thisRef: Any?, property: KProperty<*>) = prefs.getString(key, "") ?: "" override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) = prefs.edit().putString(key, value).apply() } var authToken: String by PrefDelegate(prefs, "auth_token")

Generics & variance

Generics let you write type-safe code that works across multiple types. Kotlin’s generics add declaration-site variance via in and out modifiers — a significant improvement over Java’s use-site wildcards (? extends T, ? super T). Understanding variance is the difference between a vague answer and a sharp one in an interview.

Invariant (default): MutableList<String> is NOT a MutableList<Any> — even though String is a subtype of Any. This is safe because a mutable list could have add(42) called on it, breaking the type contract.

out (covariant): out T means the class only produces T values (returns them) — it never consumes them (takes them as input). Because of this, a Producer<String> can safely be assigned to a Producer<Any>. You can only read T, not write it.

in (contravariant): in T means the class only consumes T values. A Comparator<Any> can be used as a Comparator<String> — if it can compare anything, it can certainly compare strings.

📋 Generics variance — in / out / invariant
Invariant (default) MutableList<String> MutableList<Any> Can read AND write T. Unsafe to widen. out T (covariant) Producer<out T> — only produces T Producer<String> Producer<Any> Can only read. Safe to widen. in T (contravariant) Consumer<in T> — only consumes T Consumer<Any> Consumer<String> Can only write. Safe to narrow. Star projection (*) — unknown type argument List<*> → you can read elements as Any?, but cannot write. Like List<out Any?>. Use when the type doesn’t matter — e.g. checking if something is a List without caring what’s in it.
Generics — variance, reified, upper bounds, star projection
// out (covariant) — producer, can only return T interface Source<out T> { fun next(): T } val strings: Source<String> = StringSource() val any: Source<Any> = strings // OK — Source is covariant in T // in (contravariant) — consumer, can only accept T interface Sink<in T> { fun accept(item: T) } val anySink: Sink<Any> = LogSink() val strSink: Sink<String> = anySink // OK — Sink is contravariant in T // Upper bound constraint fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b // Multiple upper bounds with where fun <T> process(item: T) where T : Serializable, T : Comparable<T> { /* ... */ } // reified — access T::class at runtime (requires inline) inline fun <reified T : Any> Bundle.getParcelable(key: String): T? = getParcelable(key, T::class.java) // Star projection — "I don’t know or care what T is" fun printList(list: List<*>) { // accepts List<Int>, List<String>, anything list.forEach { println(it) } // elements are Any? — can read but not write }

Collections & sequences

Kotlin’s collection operations (map, filter, flatMap, etc.) are eager by default: each operation processes all elements immediately and creates a new intermediate list. For short collections this is perfectly fine and often faster due to simpler iteration. But for long pipelines on large collections, this wastes both memory and CPU — you might allocate several lists before ever using the final result.

Sequences are lazy: they process one element at a time through the entire pipeline before moving to the next element. No intermediate lists are created. More importantly, sequences can short-circuit — a .first() at the end stops processing as soon as one element passes all the filters, even in a list of a million items.

Collections vs sequences — eager vs lazy evaluation
// EAGER (default) — each step processes all elements, creates intermediate lists val result = (1..1_000_000) .filter { it % 2 == 0 } // creates List of 500,000 elements .map { it * 3 } // creates another List of 500,000 elements .first() // returns 6 — but we processed a million items! // LAZY (sequence) — element by element through the entire pipeline val resultSeq = (1..1_000_000).asSequence() .filter { it % 2 == 0 } // lazy — no list created .map { it * 3 } // lazy — no list created .first() // finds 2, multiplies by 3, returns 6. Stops. Done. // Result: checked 2 elements instead of 1,000,000 // generateSequence — infinite or computed sequences val fibonacci = generateSequence(1 to 1) { (a, b) -> b to a + b } .map { it.first } .take(10) .toList() // [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] // Common collection operators worth knowing cold val items = listOf(3, 1, 4, 1, 5) items.groupBy { it % 2 } // Map<Int, List<Int>> — odd vs even items.associate { it to it * it } // Map<Int,Int> — number to its square items.partition { it > 3 } // Pair<List,List> — matching vs rest items.fold(0) { acc, n -> acc + n } // 14 — like reduce with an initial value items.windowed(3) // sliding windows of size 3 items.zipWithNext() // pairs of adjacent elements items.flatMap { listOf(it, it) } // [3,3,1,1,4,4,...] — map then flatten
✅ When to use sequences

Use asSequence() when: (1) the pipeline has 3+ chained operations, (2) there’s a terminal short-circuit operation like first() or any(), or (3) the collection is large (>1000 elements). For small collections with 1–2 operations, the overhead of a Sequence object makes it slower than eager evaluation.

Inheritance

Kotlin classes are final by default — the opposite of Java. You must explicitly mark a class open to allow subclassing, and mark individual members open to allow overriding. This is a deliberate design choice: inheritance is a fragile relationship that should be intentional, not accidental.

Inheritance — open, abstract, interface, final, super
// open — opt-in to inheritance. final by default. open class Animal(val name: String) { open fun speak(): String = "..." // open — can override fun breathe() = println("$name breathes") // not open — cannot override } class Dog(name: String) : Animal(name) { override fun speak() = "Woof!" // breathe() cannot be overridden — compiler error if you try } // abstract — implicitly open, cannot be instantiated, members may be abstract abstract class Shape { abstract fun area(): Double fun describe() = "Area is ${area()}" // can call abstract members } // Interface — can have default implementations AND abstract members interface Clickable { fun click() // abstract — must implement fun showOff() = println("I’m clickable") // default impl } // Multiple interface implementation — diamond problem handled by compiler interface Focusable { fun showOff() = println("I’m focusable") } class Button : Clickable, Focusable { override fun click() = println("Clicked") override fun showOff() { // MUST override when ambiguous super<Clickable>.showOff() // call specific super super<Focusable>.showOff() } } // final override — prevent further overriding down the chain open class RichButton : Clickable { final override fun click() = println("RichButton click") } // Class vs object vs interface — key differences // class: can have state + behaviour, instantiable, can be open/abstract/final // abstract class: cannot instantiate, subclasses must implement abstract members // interface: no state (no backing fields), multiple implementation allowed // object: singleton, cannot be instantiated, can implement interfaces/extend classes
💡 Abstract class vs interface — the interview question

Abstract class: can hold state (constructor parameters, property fields with backing storage), supports single inheritance only. Use when subclasses share common state or a partial implementation.
Interface: no backing state (properties are abstract or computed), supports multiple implementation. Use to define a contract. In Kotlin, interfaces can have default implementations, closing much of the gap with abstract classes.

Exception handling

Kotlin has no checked exceptions — this is a deliberate departure from Java. You never have to declare throws IOException or wrap code in try-catch just to satisfy the compiler. The philosophy: checked exceptions add noise but empirically don’t improve error handling; they’re often swallowed with empty catch blocks. In Kotlin you handle exceptions when you can actually do something about them.

Exception handling — try-catch, try as expression, runCatching
// try-catch-finally — standard form try { val result = parseJson(input) processResult(result) } catch (e: JsonParseException) { Log.e("TAG", "Bad JSON", e) } catch (e: NetworkException) { showNetworkError() } finally { hideLoading() // always runs } // try is an expression — returns a value val number = try { Integer.parseInt(input) } catch (e: NumberFormatException) { 0 } // throw is also an expression — can appear in Elvis chains val name = user?.name ?: throw IllegalStateException("User has no name") // runCatching — wraps in kotlin.Result, good for functional error handling val result = runCatching { api.fetchUser(id) } result .onSuccess { user -> showProfile(user) } .onFailure { error -> showError(error.message) } // getOrDefault / getOrElse / getOrThrow val user = runCatching { api.fetchUser(id) }.getOrDefault(User.ANONYMOUS) val data = runCatching { parse(raw) }.getOrElse { handleError(it) } // @Throws — annotate for Java callers who expect checked exceptions @Throws(IOException::class) fun readFile(path: String): String = File(path).readText()

Common interview gotchas

== vs === and equals() in data classes

== in Kotlin calls equals() (structural equality). === is reference equality (same object in memory). For data classes == compares field values; for regular classes it defaults to reference equality unless you override equals(). This bites people who use regular classes in sets or as map keys without overriding both equals() and hashCode().

lateinit vs lazy — they’re not interchangeable

lateinit is for var (mutable), non-null, non-primitive properties initialised after construction — like injected dependencies or Android binding objects. It throws UninitializedPropertyAccessException if accessed before assignment. by lazy is for val (immutable), computed on first access and cached. They serve different purposes and cannot be swapped.

The Nothing type

Nothing is a type with no instances — a function that returns Nothing never returns normally (it always throws or loops forever). The compiler uses this to enable smart casts after throw expressions and to type emptyList() as List<Nothing> which is a subtype of every List<T> via covariance. In sealed classes, UiState.Loading : UiState<Nothing>() means Loading is a valid UiState<T> for any T.

Companion object initialisation order

Companion object properties are initialised when the class is loaded, before any instance is created — similar to Java’s static initialiser blocks. If companion object initialisation throws, the class becomes unusable for the rest of the JVM process. Avoid heavy computation in companion object property initialisers; use by lazy to defer it.

How Kotlin language features connect to Android architecture

Kotlin’s language features aren’t just academic — they map directly to Android patterns. Sealed classes are the backbone of the UiState pattern in MVVM: Loading, Success(data), Error(message) modelled as a sealed hierarchy give the ViewModel a type-safe way to express every possible screen state. The exhaustive when in the Fragment means a new state is never silently ignored. Delegation shows up everywhere: by viewModels(), by activityViewModels(), by lazy { binding } in Fragments, and by savedStateHandle() for state preservation are all property delegates. Inline + reified powers the extension functions that make Hilt and Navigation Compose type-safe without requiring class tokens. Extension functions let you add context-specific utilities (View.visible(), String.isValidEmail()) to Android classes without subclassing. Sequences matter in data processing pipelines — mapping and filtering large lists from a database or network before rendering.

🎯 Interview summary

Null safety: ?. safe call, ?: Elvis, !! last resort, as? safe cast, ?.let null-safe block.
Data class: generates equals/hashCode/toString/copy/componentN from primary constructor only. Body properties are excluded. Cannot be open.
Sealed class vs enum: sealed subclasses can be different types with different data; enums are same type, same shape. Both give exhaustive when.
Scope functions: apply/run/with use this; let/also use it. apply/also return the object; let/run/with return lambda result.
inline: eliminates lambda object allocation by pasting body at call site. reified: access T::class at runtime in inline functions.
Extension functions: static methods under the hood; cannot access private members; member always wins over extension with same signature.
by lazy: val, computed once on first access, thread-safe. lateinit: var, non-null, initialised after construction.
out T: covariant (producer), can widen to supertype. in T: contravariant (consumer), can narrow to subtype.
Sequences: lazy, element-by-element, short-circuit. Use for 3+ operations on large collections or when terminal is first/any.
Nothing: no instances, returned by functions that always throw. Enables smart casts after throw expressions.
Checked exceptions: Kotlin has none. Use runCatching / Result for functional error handling.