Kotlin removes whole classes of Java bugs and boilerplate (nullability in the type system, data classes, coroutines) while running on the same JVM and calling Java directly; the price is build time, some hidden runtime costs and a weak spot at the Java boundary.
Advantages
Null safety: String and String? are different types, so most NullPointerExceptions become compile errors.
Less boilerplate: properties, type inference, data class, default and named arguments, string templates.
Expressive types: sealed hierarchies with an exhaustive when, smart casts, extension functions.
Coroutines and Flow: asynchronous code written top to bottom, with structured cancellation.
Ecosystem: Android has been Kotlin-first since 2019; Jetpack Compose is Kotlin-only, and Kotlin Multiplatform shares code with iOS.
Interop: Kotlin and Java classes live in one module and call each other, so a codebase can migrate file by file.
Drawbacks
Build speed: clean builds are slower than Java; the K2 compiler (default since Kotlin 2.0) and KSP instead of kapt narrow the gap.
Hidden costs: capturing lambdas, boxing of Int? and generics, companion object accessors: cheap one by one, visible in hot loops.
Platform types: values from un-annotated Java code skip the null checks and can still crash at runtime.
Many ways to write one thing: scope functions and DSLs hurt readability when overused, so a team needs conventions.
AndroidJunior
Kotlin Basics
val/var, types, null safety, control flow, functions, lambdas
Questions closed
0 / 24
- 0Review
- 6Asked often
Questions
val is a read-only reference assigned once, var can be reassigned, and const val is a compile-time constant whose value the compiler copies into every place that uses it.
**val**: like a Java final variable. The reference is fixed, but the object behind it can still change: with val list = mutableListOf(1) you can still call list.add(2).
**var**: reassignable. Default to val and switch to var only when the value really changes: less mutable state means fewer bugs and safer sharing between threads.
**const val**: only String and primitive types, only at top level or inside an object / companion object, and the value must be known at compile time. Unlike a plain val, it can be used in annotation arguments.
A plain val is computed at runtime, so val startedAt = System.currentTimeMillis() is fine but can never be const.
Because callers hold a copy, changing a const val in a library takes effect only after the modules that use it are recompiled.
Nullability is part of the type: a String can never hold null, a String? can, and the compiler will not let you use a String? as a String until you have handled the null case.
**?.** safe call returns null instead of calling when the receiver is null: user?.address?.city.
**?:** Elvis supplies a fallback when the left side is null; the right side may be return or throw: val id = intent.getStringExtra("id") ?: return.
**!!**: asserts non-null and throws NullPointerException otherwise. Every !! is a crash you agreed to; prefer Elvis or requireNotNull(x) { "message" }.
**?.let { }**: runs the block only when the value is non-null, passing it as it.
Smart cast: after if (x != null) the compiler treats x as non-null inside that branch.
Java interop: a value from un-annotated Java code has a platform type (String!) that the compiler does not check; annotate the Java side with @Nullable / @NonNull or treat the value as nullable at the boundary.
It prints null, -1, true and Hello, null, and then the last line throws NullPointerException: only !! crashes, every other operator here handles null on purpose.
name?.length: the safe call skips length, the whole expression is null, and println prints null.
name?.length ?: -1: Elvis replaces that null with -1.
name.isNullOrEmpty(): an extension declared on String?, so it needs no ?. and returns true for null.
"Hello, $name": a string template calls toString() on null and inserts the text null; this is how "Hello, null" ends up in a real UI. Write ${name ?: "guest"}.
name!!.length asserts non-null, so it throws NullPointerException.
lateinit var is for a non-null value that something else assigns later, such as Hilt or onCreate; by lazy is for a val that computes itself on first read and caches the result. ::prop.isInitialized checks a lateinit.
lateinit var
Only var, only non-null, and not a primitive type such as Int or Boolean; for those use Delegates.notNull().
Reading it before assignment throws UninitializedPropertyAccessException; ::binding.isInitialized lets you check first.
Typical uses: a field injected by Hilt, a view binding set in onCreate, an object under test created in @Before.
by lazy { }
Only val. The block runs once, on first access, and the result is cached.
Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED); LazyThreadSafetyMode.NONE drops the lock when only one thread, such as the main thread, reads it.
Typical uses: an expensive object a screen may not need, such as a formatter, a parser, a large adapter.
Do not use lateinit for a value that may legitimately be missing: a nullable var makes that state visible to the compiler instead of turning it into a crash.
== checks structural equality by calling equals(), while === checks referential identity: whether both references point to the very same object.
a == b compiles to a?.equals(b) ?: (b === null), so it is null-safe and never throws. === cannot be overloaded.
Two data class instances with the same properties are == but not ===. For your own class, == means whatever equals() says; override hashCode() together with it, or HashSet and HashMap stop finding your objects.
Arrays: == on arrays compares references; use contentEquals().
Boxed numbers: Int? values from -128 to 127 come from the JVM's Integer cache, so === happens to be true for 127 and false for 128. Never compare numbers with ===; the compiler warns about it.
when is Kotlin's switch, but it is an expression that returns the value of the matching branch; when you use that value, the branches must cover every input, through else or by listing every case of an enum, sealed type or Boolean.
A branch can match a value (0 ->), several values (200, 204 ->), a range (in 1..9 ->), a type (is String ->, which also smart-casts) or, in a when without a subject, any boolean condition.
if/else and try/catch are expressions too, so val label = if (ok) "Done" else "Retry" replaces the ternary operator Kotlin does not have.
Over a sealed or enum type, leave out else: when someone adds a new subtype, every when that misses it stops compiling. An else branch would hide it.
Since Kotlin 1.7, a when statement over a sealed, enum or Boolean subject must be exhaustive as well.
Since Kotlin 2.2, a branch can carry a guard condition: is UiState.Content if state.items.isEmpty() -> "Nothing here".
A range start..end includes both ends; ..< and until exclude the end, downTo counts backwards, and step changes the stride.
for (i in 0..<list.size) is the safe index loop; 0..list.size goes one past the last index and throws IndexOutOfBoundsException. Clearer still: list.indices or withIndex().
..< has been stable since Kotlin 1.9; until is the older spelling of the same thing.
A backwards .. is empty, not an error: for (i in 5..1) runs zero times. Use 5 downTo 1.
in and !in test membership without iterating: if (code in 200..299). Any Comparable works with in, but only integer and Char ranges can be looped over.
In a for header a range compiles to a plain counting loop with no allocation; use repeat(n) { } when you do not need the counter at all.
A lambda is a function literal such as { x: Int -> x * 2 } that you can store or pass around; a higher-order function takes a function as a parameter or returns one, like filter, map or your own retry(block: () -> Boolean).
Syntax: parameters before ->; a single parameter is available as it; the last expression is the result.
Function types: (Int) -> String, () -> Unit, suspend () -> Unit; nullable as ((Int) -> Unit)?.
Trailing lambda: when the last parameter is a function, the lambda goes outside the parentheses: items.filter { it.isActive }.
Function references: ::isValid, String::trim, viewModel::onClick pass an existing function instead of a new lambda.
Closures: a lambda can read and change variables of the enclosing scope.
What it compiles to
On the JVM a lambda is an object implementing a function interface such as Function1. Since Kotlin 2.0 it is generated through invokedynamic, like a Java lambda, instead of a separate class per lambda.
A lambda that captures nothing is created once and reused; a capturing one allocates an object per call, and a captured var is wrapped in a Ref object.
An inline higher-order function (forEach, map, let) copies the lambda body into the call site, so no object is created at all.
It prints 1, 2, 1: each call to makeCounter() creates its own count, and the returned lambda captures it, so first and second keep separate counters that outlive the function call.
The lambda is a closure: it holds a reference to count, not a copy, so ++count changes the captured variable and the next call sees the new value.
A plain local var disappears when its function returns. Because a lambda captures and modifies this one, the compiler moves it into a small heap object (Ref.IntRef) shared by the function and the lambda.
second comes from a separate call, so it gets its own IntRef starting at 0.
Captured mutable state is not thread-safe: calling first() from two threads at once can lose increments. Use AtomicInteger when the lambda is shared between threads.
A parameter can declare a default value, so callers pass only what differs, and any argument can be passed by name; together they replace telescoping constructors and most hand-written Builders.
Named arguments make call sites readable (createUser(name = "Ada", isAdmin = true) instead of a bare true) and let you skip a parameter in the middle.
Defaults are evaluated on every call and may use earlier parameters: fun load(page: Int = 0, size: Int = if (page == 0) 50 else 20).
A data class with defaults plus copy(timeoutMs = 60_000) covers "the same object with one change" without a Builder.
An overriding function inherits the defaults and is not allowed to declare its own.
Java interop: Java sees only the full signature; @JvmOverloads generates the shorter overloads. A custom View needs it, because XML inflation calls the (Context, AttributeSet) constructor.
Add new parameters at the end: inserting one of the same type in the middle keeps positional calls compiling while changing what they mean.
List is a read-only interface with no add or remove, MutableList adds them; val vs var is a separate choice: it decides whether the variable can point to another list, not whether the list can change.
val items = mutableListOf<Int>(): the contents change (items.add(1)), the reference never does.
With var items = listOf<Int>() the list is never modified; each update builds a new list and reassigns it: items = items + 1. This is the shape StateFlow and Compose state expect: they react only when the new value is not equal to the old one, and a list mutated in place is always equal to itself.
A var holding a MutableList gives two ways to mutate and is almost always a mistake.
Read-only is not immutable: a MutableList can be passed where a List is expected, and whoever holds the mutable reference can still change it. Expose List from a class, keep the MutableList private, and copy with toList() when you need a snapshot.
For a real guarantee use kotlinx.collections.immutable (persistentListOf()).
The same pairs exist for Set / MutableSet and Map / MutableMap.
They are standard-library extensions that build a new collection or value without changing the original: map converts each element, fold/reduce combine all elements into one value, and the rest regroup elements into lists, maps or pairs.
**map / flatMap**: map gives one result per element; flatMap expects a list per element and flattens them into one list: orders.flatMap { it.items }.
**filter / mapNotNull**: filter keeps matching elements; mapNotNull maps and drops nulls in one pass.
**fold vs reduce**: fold(initial) starts from your value and works on an empty list; reduce starts from the first element and throws on an empty list. For totals, sumOf { } is simplest.
**groupBy**: Map<K, List<V>>: every element is kept under its key.
**associateBy**: Map<K, V>: one element per key, and a later duplicate silently replaces an earlier one.
**partition**: splits into a Pair of matching and non-matching lists in one pass.
**zip**: pairs elements by position and stops at the end of the shorter list.
Each step allocates a new list; asSequence() makes a long chain lazy.
Arrays are fixed-size JVM arrays that compare by reference, so == is false for two arrays with the same contents; use List by default and arrays only where an API, vararg or primitive performance needs them.
Arrays do not override equals, so == falls back to identity; compare with contentEquals() and print with contentToString().
A data class with an Array property gets equals and hashCode that ignore the contents; hold a List instead.
Array<Int> is Integer[] and boxes every element; IntArray, LongArray, ByteArray and the others map to primitive int[], long[], byte[].
**vararg** parameters arrive as an array; pass an existing array with the spread operator: log(*parts).
Typical array uses: ByteArray for files, network and crypto, numeric hot loops, and Java or Android APIs that take arrays.
Convert with toList(), toIntArray() and toTypedArray().
val (a, b) = obj calls obj.component1() and obj.component2() in order; the names you write do not matter, only the position.
A data class generates componentN() for each primary-constructor property; Pair, Triple and Map.Entry have them too.
Common uses: for ((key, value) in map), for ((index, item) in list.withIndex()), lambda parameters such as map.forEach { (k, v) -> }, and returning two values through a small data class.
_ skips a component you do not need.
Any class can support it by declaring operator fun component1(), component2() and so on.
The trap: because it is positional, swapping two properties of the same type in the constructor keeps every destructuring site compiling while silently assigning the wrong values. Keep destructuring to small types with an obvious order and read wider models by property name.
String is immutable, so each += allocates a new string and copies every character so far: n appends cost O(n²); StringBuilder, usually through buildString { } or joinToString(), appends into one growing buffer.
One template or a + b + c in a single expression is fine: the compiler turns it into one efficient concatenation. The cost appears only across loop iterations.
**buildString { append(...) }**: gives you a StringBuilder inside the block and returns the finished string.
**joinToString(", ") { it.name }**: the usual case of joining a list, with prefix, postfix and limit parameters instead of separator bookkeeping.
**StringBuffer**: the synchronized legacy twin of StringBuilder; build strings on one thread and you never need it.
After a check such as x is String or x != null, the compiler treats x as the narrower type in that branch without an explicit cast, but only when it can prove the value cannot change between the check and the use.
Where it works
Local vals always; local vars when they are not reassigned between the check and the use.
val properties declared in the same module that are not open and have no custom getter.
In when (x) { is Foo -> ... }, on the right of &&, and after an early return or throw in the opposite branch.
Where it does not
A var property: another thread or a call in between could reassign it. Copy it into a local first.
A val with a custom getter, an open property, or one from another module: each read may return something different.
The K2 compiler (default since Kotlin 2.0) accepts more cases than the old one, for example a common supertype after || and local variables used in lambdas.
as and as?
x as String casts and throws ClassCastException on a mismatch.
x as? String returns null instead, so it pairs with Elvis: (x as? String) ?: "".
?.let { } ?: showGuestBanner() checks the result of the lambda, not user: lastSeenName.put() returns the previous value, which is null on a first visit, so a signed-in user sees the guest banner too.
Elvis looks at the whole left side: user?.let { … } is null when user is null or when the block itself returns null. MutableMap.put() returns the old value for the key, so it is null exactly on the first visit.
Why it slips through: the screen works for anyone who has opened it before, so a quick manual test passes. Any block whose last call returns T? has the same trap.
The fix: two branches of logic deserve a real if (user != null) … else …: smart cast makes user non-null inside, and the else depends only on user. Keep ?: for picking a value, as in user?.name ?: "guest".
If you want to keep the chain: user?.also { … } ?: showGuestBanner() works because also returns its receiver, but the reader has to know that; if/else cannot be misread.
Catch it: a unit test with an empty map and a non-null user that asserts no banner; in review, treat ?.let { … } ?: … used for control flow as a red flag.
Yes, val only means there is no setter; a val with a custom getter has no backing field and runs the getter on every read, so it can return a new value each time.
A Kotlin property is a getter, an optional setter and an optional backing field. The field exists only when a default accessor is used or an accessor refers to field.
val isEmpty get() = items.isEmpty() stores nothing and recomputes on each read; val createdAt = now() stores once.
Inside a custom setter, write to field; assigning to the property itself calls the setter again and ends in StackOverflowError.
var count = 0 followed by private set gives a public read and a private write, the usual way to expose state from a class.
Keep computed properties cheap and free of side effects, because callers treat a read as nearly free; heavy work belongs in a function or by lazy.
This is also why a smart cast does not work on a val with a custom getter.
by lazy computes authHeader once, on the first request, and caches it for the life of the single ApiClient, so after a re-login every request still sends the previous user's token and the server answers 401.
**What lazy promises**: the initializer runs on the first read and the result is stored for good; later changes to session.token are never seen. lazy fits values that never change once computed, such as a formatter or a parsed config.
Worse variant: if the first request goes out before login, the cached value is "Bearer null": a string template turns null into the text null, and the client stays broken until the process dies.
The fix: a custom getter, get() = "Bearer ${session.token}", has no backing field and reads the current token on every call; building one string per request costs nothing next to a network call.
In a real app: attach the token in an OkHttp Interceptor that reads the session at request time, and handle a missing token explicitly instead of sending Bearer null.
Catch it: a test that changes the token between two request() calls and asserts the second header; be suspicious of by lazy on any value that can change after a logout.
Kotlin has no separate primitive types in the language (Int looks like any class), but the compiler stores a non-null Int, Long, Double or Boolean as a JVM primitive wherever it can and boxes it only when an object is required.
Boxed: a nullable Int?, a generic type argument (List<Int>, Map<String, Int>, T), a value assigned to Any, and Array<Int>.
Not boxed: locals, properties, parameters and return values of type Int, and IntArray.
Boxing costs an allocation (outside the -128..127 cache) and an extra pointer hop. It matters in hot loops, large collections and per-frame code: use IntArray or androidx.collection maps such as MutableIntIntMap.
No implicit widening: val total: Long = count does not compile when count is an Int; write count.toLong(). A literal takes its type from context, so val x: Long = 1 is fine.
Arithmetic stays typed: Int / Int is integer division (1 / 2 == 0), and Int overflow wraps silently (Int.MAX_VALUE + 1 is negative); use Long for timestamps and money in minor units.
Any is the root of all non-null types with any number of instances, Unit has exactly one instance and plays the role of Java's void, and Nothing has no instances at all: it is the type of an expression that never completes.
**Any**: maps to java.lang.Object and declares only equals(), hashCode() and toString(). Any? is the top of the whole hierarchy, null included.
**Unit**: a singleton object. A function with no declared return type returns Unit; on the JVM it compiles to void, but as a real type it works as a generic argument: () -> Unit, Result<Unit>.
**Nothing**: the type of throw, TODO(), error() and an infinite loop. It is a subtype of every type, so val id: String = raw ?: throw IllegalStateException() type-checks, and code after it is marked unreachable.
A function declared fun fail(msg: String): Nothing can stand in any branch without widening the result type.
Nothing? has exactly one value, null: val x = null is inferred as Nothing?, and a List<Nothing> fits any List<T> because List is covariant.
No, nothing in a Kotlin signature forces the caller to catch, so expected failures go into the return type (a sealed result or Result<T>), and exceptions are kept for bugs and truly exceptional conditions.
try/catch is an expression: val port = try { raw.toInt() } catch (e: NumberFormatException) { 8080 }.
require(cond) throws IllegalArgumentException for a bad argument, check(cond) throws IllegalStateException for a broken state, error("msg") always throws.
Expected outcomes: wrong password, no network, payment declined. Model them as a sealed interface such as Success / WrongPassword / Offline, so when makes the caller handle each case.
runCatching { } returns Result<T> but catches every Throwable, CancellationException included; inside coroutines rethrow it, or cancellation stops working.
Java callers cannot tell that a Kotlin function throws: add @Throws(IOException::class) so Java has to catch it and Swift (through Kotlin/Native) sees throws.
Why Kotlin dropped them: in large Java codebases checked exceptions mostly produced empty catch blocks and throws Exception chains, and they do not compose with lambdas.
It prints only a: forEach is an inline function, so a bare return inside its lambda returns from printVisible itself, and neither b nor done is printed. Writing return@forEach (or, since Kotlin 2.2, a plain continue) skips just the current item.
Inlining copies the lambda body into the calling function, so return has no lambda to leave and exits the enclosing function. This is a non-local return.
Every lambda has an implicit label named after the function it is passed to: return@forEach works like continue (on Kotlin 2.2+ a plain continue does the same), and return@map value returns a value for one element.
Loops take explicit labels: outer@ for (row in grid) { for (cell in row) if (cell == 0) break@outer }.
A lambda passed to a non-inline function, or to a crossinline parameter, may run after the enclosing function has finished, so the compiler rejects a bare return there and accepts only a labeled one.
Before Kotlin 2.2 forEach had no break; since 2.2 break and continue work directly inside lambdas of inline functions such as forEach and any. On older versions use a plain for loop with break, or firstOrNull { }, any { }, takeWhile { }. Inside a lambda of a non-inline function they are still rejected.