Skip to content

Kotlin Cheatsheet

Modern JVM language, concise and fully Java-interoperable.

01

Basics

Variables & Types

Prefer val (immutable) over var (mutable). Kotlin infers types but allows explicit annotations. Use 'is' for type checks (smart-casts automatically). Everything is an object—no primitives in Kotlin syntax.

kotlin
val name = "Alice"   // immutable (preferred)
var age = 30          // mutable
val pi: Double = 3.14159
val isDev: Boolean = true
val nums: List<Int> = listOf(1, 2, 3)
println(name::class)  // class kotlin.String
println(age is Int)   // true

String Templates

$var for simple variables, ${expr} for expressions. Triple-quoted strings preserve newlines—use trimIndent() for clean formatting. String templates make string building concise and readable without format strings.

kotlin
val name = "Alice"
val age = 30
println("Name: $name, Age: $age")  // simple var
println("Length: ${name.length}")  // expression
println("Upper: ${name.uppercase()}")
val multiline = """
  Hello, $name
  Age: $age
""".trimIndent()

Null Safety

Kotlin's null safety: ? marks nullable types, ?. safe call (returns null if null), !! asserts non-null (throws NPE if null), ?: elvis (default if null). This eliminates NullPointerException at compile time—core Kotlin feature.

kotlin
var name: String? = null  // nullable type
println(name?.length)     // null (safe call)
name = "Alice"
println(name!!.length)    // 5 (non-null assertion)
val len: Int = name?.length ?: 0  // elvis operator
// Non-null types can never be null
val s: String = "hi"  // not nullable

Type Conversion

Kotlin requires explicit type conversion (no implicit widening like Java). Use toInt()/toDouble()/toString() for conversion. toIntOrNull() returns null on failure—safer than toInt() which throws. Always handle null from parsing.

kotlin
val n: Int = 42
val d: Double = n.toDouble()
val s: String = n.toString()
val fromStr: Int? = "100".toIntOrNull()
val invalid: Int? = "abc".toIntOrNull()  // null
println(d, s, fromStr, invalid)

Type Checks & Smart Cast

is checks type and smart-casts the variable in that branch—no explicit cast needed. !is is the negation. Use as for unsafe casts (throws ClassCastException), as? for safe casts (returns null on failure). Smart cast is a major Kotlin convenience.

kotlin
fun describe(x: Any): String =
    when (x) {
        is Int -> "Int: ${x + 1}"  // x smart-cast to Int
        is String -> "String of length ${x.length}"
        is List<*> -> "List with ${x.size} items"
        else -> "Unknown"
    }
println(describe(42))      // Int: 43
println(describe("hello")) // String of length 5
02

Strings

Common String Methods

Kotlin strings have rich extension functions from the standard library. Most return new strings (immutable). Use these instead of manual loops. Functions like uppercase() are locale-aware—use uppercase(Locale.ROOT) for consistent results.

kotlin
val s = "Hello, World"
println(s.length)          // 12
println(s.uppercase())     // HELLO, WORLD
println(s.lowercase())     // hello, world
println(s.split(", "))     // [Hello, World]
println(s.replace("o", "0"))  // Hell0, W0rld
println(s.reversed())      // dlroW ,olleH
println(s.startsWith("Hello"))  // true

Multiline & Raw Strings

Triple-quoted strings are raw—no escape sequences needed (except triple double-quotes). trimMargin('|') keeps only text after | for clean indentation. trimIndent() removes common whitespace. Ideal for SQL, JSON, HTML in code.

kotlin
val text = """
    |Hello,
    |World!
""".trimMargin()  // | marks line start
println(text)
val json = """{"name": "Alice", "age": 30}"""
println(json)  // no escape needed for quotes
// trimIndent() removes common leading whitespace

String Building

joinToString is the idiomatic way to join collections with separators, prefix, postfix. buildString provides a StringBuilder scope for concise building. Avoid repeated + in loops—use joinToString or StringBuilder for efficiency.

kotlin
val parts = listOf("apple", "banana", "cherry")
println(parts.joinToString(", "))  // apple, banana, cherry
println(parts.joinToString(prefix="[", postfix="]"))
val sb = StringBuilder()
for (p in parts) sb.append(p).append(" ")
println(sb.toString().trim())
// buildString for concise building
val result = buildString {
    parts.forEach { append(it).append(";") }
}

String to Number

toInt()/toDouble() throw NumberFormatException on invalid input. toIntOrNull() returns null—pair with ?: for safe defaults. toBooleanStrict() only accepts 'true'/'false'. Use OrNull variants for user input or untrusted data.

kotlin
val n = "42".toInt()           // 42 (throws if invalid)
val d = "3.14".toDouble()      // 3.14
val safe = "abc".toIntOrNull() // null
val withDefault = "x".toIntOrNull() ?: 0  // 0
val bool = "true".toBooleanStrict()  // true
println(n, d, safe, withDefault)

Regex

Regex class wraps Java's Pattern. find returns MatchResult? (first match), findAll returns all. matches checks full match, containsMatchIn checks partial. Use triple-quoted strings for patterns to avoid double-escaping backslashes.

kotlin
val email = Regex("[\w.]+@[\w]+\.[a-z]+")
val text = "Contact: [email protected]"
val match = email.find(text)
println(match?.value)  // [email protected]
println(email.matches("[email protected]"))  // true
val replaced = text.replace(Regex("\d+"), "#")
println(email.containsMatchIn(text))  // true
03

Data Structures

List

listOf creates immutable lists, mutableListOf for mutable. Lists are zero-indexed. Use first()/last() for ends (throw on empty), firstOrNull() for safety. contains checks membership. Prefer immutable lists unless you need to modify.

kotlin
val nums = listOf(1, 2, 3)  // immutable
val mutable = mutableListOf(1, 2, 3)
mutable.add(4)
mutable[0] = 0
println(nums.size)         // 3
println(nums.first())      // 1
println(nums.last())       // 3
println(nums.contains(2))  // true
println(nums.indexOf(2))   // 1

Map

mapOf creates immutable maps, mutableMapOf for mutable. 'to' infix creates pairs. [] access returns nullable V? (null if missing). Use getOrDefault or getValue (throws if missing). Iterate with destructuring (k, v).

kotlin
val ages = mapOf("Alice" to 30, "Bob" to 25)
println(ages["Alice"])          // 30
println(ages.getOrDefault("Eve", 0))  // 0
println(ages.containsKey("Alice"))    // true
for ((k, v) in ages) println("$k: $v")
val mutable = mutableMapOf("a" to 1)
mutable["b"] = 2  // add/update

Set

setOf creates immutable sets, mutableSetOf for mutable. union/intersect/subtract return new sets. Sets enforce uniqueness—add returns false if element exists. Use for deduplication and set operations. LinkedHashSet preserves insertion order.

kotlin
val a = setOf(1, 2, 3)
val b = setOf(3, 4, 5)
println(a union b)        // [1,2,3,4,5]
println(a intersect b)    // [3]
println(a subtract b)     // [1,2]
println(a.contains(2))    // true
val mutable = mutableSetOf(1, 2)
mutable.add(3)  // true if added

Array & Primitive Arrays

arrayOf creates Array<T> (boxed for primitives). Use intArrayOf/doubleArrayOf for unboxed primitive arrays (better performance). Array(size) { init } creates with an init function. Arrays are mutable; prefer List for most use cases.

kotlin
val arr = arrayOf(1, 2, 3)  // Array<Int>
arr[0] = 10
println(arr.size)  // 3
// Specialized arrays for primitives (no boxing)
val ints = intArrayOf(1, 2, 3)
val doubles = doubleArrayOf(1.0, 2.0)
// Array constructor
val squares = Array(5) { it * it }  // [0,1,4,9,16]
println(squares.toList())

Pair & Triple

Pair and Triple group 2-3 values. 'to' infix creates Pairs (common for maps). Destructure with val (a, b) = pair. For more than 3 values or named fields, use data classes—they're clearer and more maintainable.

kotlin
val pair = "Alice" to 30  // Pair<String, Int>
println(pair.first)   // Alice
println(pair.second)  // 30
val (name, age) = pair  // destructuring
val triple = Triple(1, "a", 2.0)
println(triple.first, triple.third)
// Useful for returning two values
fun minMax(list: List<Int>): Pair<Int, Int> =
    Pair(list.min(), list.max())
04

Control Flow

If / Else (Expression)

In Kotlin, if/else is an expression that returns a value. This replaces the ternary operator. Both branches must return compatible types. Use for concise conditional assignment. As a statement (no else), it returns Unit.

kotlin
val score = 85
val grade = if (score >= 90) "A"
            else if (score >= 80) "B"
            else if (score >= 70) "C"
            else "F"
println(grade)  // B
// if returns a value—no ternary needed
// Both branches must have compatible types

When (Switch)

when is Kotlin's powerful switch—supports literals, ranges (in), negation (!in), multiple values (comma), and is-checks. Must be exhaustive when used as expression with sealed types. Without a subject, it's a cleaner if/else chain.

kotlin
val n = 2
val label = when (n) {
    0 -> "zero"
    1, 2, 3 -> "small"
    in 4..10 -> "medium"
    !in 1..100 -> "out of range"
    else -> "large"
}
println(label)  // small
// When without subject = multi-condition if
when {
    n > 0 -> println("positive")
    n < 0 -> println("negative")
    else -> println("zero")
}

For Loops & Ranges

.. is inclusive range, until is exclusive, downTo counts down, step sets increment. withIndex() gives indexed iteration. for-in works with any Iterable. These ranges are concise and readable for counting loops.

kotlin
for (i in 0 until 5) print(i)  // 01234 (excludes 5)
for (i in 1..5) print(i)      // 12345 (includes 5)
for (i in 5 downTo 1) print(i)  // 54321
for (i in 1..10 step 2) print(i)  // 13579
val list = listOf("a", "b", "c")
for ((index, value) in list.withIndex()) {
    println("$index: $value")
}

While & Do-While

while checks before, do-while checks after (runs at least once). Both require mutable state. Prefer for loops or functional operations (map, filter) for immutability. Use while for condition-driven loops where the count isn't known.

kotlin
var count = 0
while (count < 3) {
    println(count)
    count++
}
var j = 0
do {
    println(j)
    j++
} while (j < 3)
// do-while runs at least once
// Prefer for loops when possible

Break & Continue (Labels)

continue skips to next iteration, break exits the loop. Labels (@outer) enable breaking out of nested loops—define with label@ before the loop, break/continue with @label. Use sparingly; refactoring to functions is often cleaner.

kotlin
for (i in 1..5) {
    if (i == 3) continue  // skip 3
    if (i == 5) break     // stop at 5
    println(i)  // 1, 2, 4
}
// Labeled breaks for nested loops
outer@ for (i in 1..3) {
    for (j in 1..3) {
        if (i == 2 && j == 2) break@outer
        println("$i,$j")
    }
}
05

Functions & Lambdas

Function Definition

Functions use fun name(params): ReturnType. Expression body (= expr) for single expressions. Default params and named arguments supported. Unit return type = side effect only. Default params reduce the need for overloading.

kotlin
fun add(a: Int, b: Int): Int = a + b  // expression body
fun greet(name: String, greeting: String = "Hello"): String {
    return "$greeting, $name!"
}
println(add(3, 4))           // 7
println(greet("Alice"))      // Hello, Alice!
println(greet("Bob", greeting = "Hi"))  // named arg
fun log(msg: String): Unit = println(msg)  // Unit = void

Lambdas & Higher-Order

Lambdas: { params -> body }. 'it' is shorthand for a single parameter. Pass lambdas to higher-order functions like map/filter/reduce. Trailing lambda syntax: if the last param is a function, it can go outside (). This is idiomatic Kotlin.

kotlin
val square: (Int) -> Int = { x -> x * x }
println(square(5))  // 25
val nums = listOf(1, 2, 3)
println(nums.map { it * 2 })      // [2, 4, 6]
println(nums.filter { it > 1 })   // [2, 3]
println(nums.reduce { a, b -> a + b })  // 6
// 'it' is the implicit single parameter

Extension Functions

Extension functions add methods to existing classes (even from Java) without inheritance or modification. They're syntactic sugar for static functions. 'this' refers to the receiver. Use to make APIs fluent. Resolved at compile time (no dynamic dispatch).

kotlin
fun String.shout(): String = this.uppercase() + "!"
fun Int.isEven(): Boolean = this % 2 == 0
println("hello".shout())  // HELLO!
println(4.isEven())       // true
// Extensions don't modify the class—they're resolved statically
// Use to add utility methods to any type

Inline & Infix

inline copies the function body to call sites (eliminates lambda overhead). Use for higher-order functions in hot paths. infix enables function calls without dot/parens (a op b)—use for DSLs and readable operations like 'to', 'in', 'and'.

kotlin
inline fun measure(block: () -> Unit) {
    val start = System.currentTimeMillis()
    block()
    println("Took ${System.currentTimeMillis() - start}ms")
}
measure { println("working...") }
// Infix functions for readable calls
infix fun Int.times(str: String): String = str.repeat(this)
println(3 times "ab")  // ababab

Vararg & Spread

vararg accepts variable arguments (collected into an array). * spreads an array into vararg. Named params can follow vararg. Use for flexible APIs like listOf(), printf-style functions. The spread operator is Kotlin's equivalent of JS spread.

kotlin
fun sum(vararg nums: Int): Int = nums.sum()
println(sum(1, 2, 3, 4))  // 10
// Spread operator to pass array
val arr = intArrayOf(1, 2, 3)
println(sum(*arr))  // 6
// Named/default params can follow vararg
fun greet(vararg names: String, greeting: String = "Hi") =
    names.joinToString { "$greeting, $it!" }
println(greet("A", "B"))
06

Classes & OOP

Class & Constructor

Primary constructor is in the class header. val/var params become properties (with getters/setters). Without val/var, they're just constructor params (not accessible). The init block runs during construction. Kotlin generates getters/setters automatically.

kotlin
class Person(val name: String, val age: Int) {
    fun greet(): String = "Hi, I'm $name"
    fun isAdult(): Boolean = age >= 18
}
val p = Person("Alice", 30)
println(p.greet())   // Hi, I'm Alice
println(p.name)      // Alice (val = property)
println(p.isAdult()) // true
// val/var in constructor = auto property

Data Class

data class auto-generates equals, hashCode, toString, copy, and componentN (for destructuring). Use for data holders. Must have at least one val/var param. copy() creates a modified clone—great for immutable updates. Destructure with val (a, b) = point.

kotlin
data class Point(val x: Int, val y: Int)
val p1 = Point(3, 4)
val p2 = Point(3, 4)
println(p1 == p2)        // true (value equality)
println(p1.copy(x = 5))  // Point(x=5, y=4)
println(p1)              // Point(x=3, y=4)
// Auto: equals, hashCode, toString, copy, componentN
val (x, y) = p1  // destructuring via componentN()

Sealed Class

Sealed classes restrict subtypes to the same file/kotlin module. when expressions are exhaustive (compiler checks all cases). Use for ADTs (Algebraic Data Types) representing finite states. Combined with when, this enables safe, compile-checked pattern matching.

kotlin
sealed class Result
data class Success(val value: Int) : Result()
data class Failure(val error: String) : Result()
object Loading : Result()
fun handle(r: Result): String = when (r) {
    is Success -> "Got ${r.value}"
    is Failure -> "Error: ${r.error}"
    Loading -> "Loading..."
}
// when is exhaustive—compiler enforces all cases

Object & Companion

object declares a singleton (one instance, lazily initialized). companion object inside a class holds 'static' members (accessed via ClassName.member). Use object for singletons, companion for factory methods and constants. Companion can implement interfaces.

kotlin
object Config {  // singleton
    val version = "1.0"
    fun load() = mapOf("key" to "value")
}
println(Config.version)  // 1.0
class User(val name: String) {
    companion object {
        fun create(name: String) = User(name)
        const val MAX = 100
    }
}
val u = User.create("Alice")  // like static method

Inheritance & Interface

Classes are final by default—use 'open' to allow inheritance. 'override' is required. Interfaces can have default implementations. A class extends one class but implements multiple interfaces. Use abstract for partial implementation. Prefer composition over inheritance.

kotlin
open class Animal(val name: String) {
    open fun speak() = "..."
}
class Dog(name: String) : Animal(name) {
    override fun speak() = "Woof"
}
interface Greetable {
    val name: String
    fun greet(): String  // can have default impl
}
class Person(override val name: String) : Greetable {
    override fun greet() = "Hi, $name"
}
07

Null Safety & Scope Functions

Safe Calls & Elvis

?. safe call (returns null if receiver is null), ?: elvis (provides default). Chain ?. for deep access (user?.address?.city). Use ?: return/throw for early exits. These make null handling concise and safe—no explicit null checks needed.

kotlin
var name: String? = "Alice"
println(name?.length)      // 5
name = null
println(name?.length)      // null
println(name?.length ?: 0) // 0 (elvis: default if null)
println(name?.length ?: return)  // early return
// Safe call chain
val city: String? = user?.address?.city

let (Null Check)

let executes a block only if the value is non-null. 'it' is the non-null value inside the block. Use for null-guarded operations instead of if-null checks. Common pattern: value?.let { ... } for safe processing. Returns the block's result.

kotlin
var name: String? = "Alice"
name?.let {
    println("Name is $it")  // only runs if not null
    println("Length: ${it.length}")  // it is non-null
}
// Common for null-guarded blocks
val result = name?.let {
    process(it)  // it: String (non-null)
} ?: "default"

apply & also

apply configures an object (returns the object, 'this' receiver)—ideal for builders. also performs side effects (returns the object, 'it' param)—good for logging/debugging in chains. Both return the original object, enabling fluent chains.

kotlin
val list = mutableListOf<Int>().apply {
    add(1); add(2); add(3)  // 'this' = the list
    println("Built $size items")
}
val config = Config().also {
    it.timeout = 30  // 'it' = config
    println("Configured")
}
// apply: configure and return object (this)
// also: side effects, return object (it)

run & with

run executes a block on an object (returns block result, 'this' receiver)—use for transforming an object. with is like run but takes the object as a parameter (not chainable on nullables). Use for grouping operations on the same object.

kotlin
val result = "Hello".run {
    length  // 'this' = string, returns last expr
}  // 5
val r2 = with(StringBuilder()) {
    append("a"); append("b")
    toString()  // returns this
}
// run: object.run { } - returns block result
// with: with(obj) { } - returns block result (not chainable)

TakeIf & TakeUnless

takeIf returns the object if the predicate is true, null otherwise. takeUnless is the opposite. Use for conditional filtering in chains—avoids separate if checks. Combine with ?: for defaults. Elegant for validation pipelines.

kotlin
val age = 25
val valid = age.takeIf { it >= 18 }  // 25 (if true)
val invalid = age.takeIf { it < 18 }  // null (if false)
val adult = age.takeUnless { it < 18 }  // 25
// Useful for filtering in chains
val email = getUser()?.email?.takeIf { it.contains("@") }
println(valid, invalid, adult)
08

Collections & Functional

Map / Filter / Fold

map transforms, filter selects, reduce/fold aggregate. 'it' is the implicit element. fold takes a seed; reduce doesn't (throws on empty). These are the core of functional collection processing—use instead of loops for clarity.

kotlin
val nums = listOf(1, 2, 3, 4, 5)
println(nums.map { it * 2 })          // [2,4,6,8,10]
println(nums.filter { it % 2 == 0 })  // [2,4]
println(nums.reduce { a, b -> a + b }) // 15
println(nums.fold(0) { a, b -> a + b }) // 15
println(nums.sum())                   // 15
println(nums.joinToString(", "))      // 1, 2, 3, 4, 5

FlatMap & GroupBy

flatten removes one level of nesting. flatMap maps and flattens in one step—essential for nested transformations. groupBy partitions by a key into a Map. These are powerful for data processing and analysis pipelines.

kotlin
val nested = listOf(listOf(1, 2), listOf(3, 4))
println(nested.flatten())         // [1,2,3,4]
println(nested.flatMap { it.map { n -> n * 2 } })  // [2,4,6,8]
val words = listOf("apple", "bat", "ant")
val byFirst = words.groupBy { it.first() }
// {a=[apple, ant], b=[bat]}
println(byFirst)

Sorting

sorted/sortedDescending sort naturally. sortedBy/sortedByDescending sort by a key selector. These return new lists (immutable). For mutable lists, use sort/sortBy (in-place). Use key selectors for sorting by a specific field.

kotlin
val nums = listOf(3, 1, 4, 1, 5)
println(nums.sorted())           // [1,1,3,4,5]
println(nums.sortedDescending()) // [5,4,3,1,1]
val people = listOf("Alice" to 30, "Bob" to 25)
val byAge = people.sortedBy { it.second }  // by age
println(byAge)
val byNameDesc = people.sortedByDescending { it.first }
println(byNameDesc)

Sequences (Lazy)

Sequences are lazy—operations are deferred until a terminal operation (toList, sum, count). Avoids intermediate collections for better performance on large data. Use asSequence() for multi-step pipelines on big collections. Like Java Streams.

kotlin
val nums = (1..1000000).toList()
// Eager: creates intermediate lists
val eager = nums.filter { it % 2 == 0 }.map { it * 2 }.take(5)
// Lazy: no intermediate collections
val lazy = nums.asSequence()
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .take(5)
    .toList()  // forces evaluation
println(lazy)  // [4, 8, 12, 16, 20]

Partition & Chunked

partition splits into two lists by a predicate (returns Pair). chunked divides into fixed-size lists. windowed creates sliding windows. These are useful for batching, pagination, and sliding-window algorithms. All return new collections.

kotlin
val nums = listOf(1, 2, 3, 4, 5)
val (evens, odds) = nums.partition { it % 2 == 0 }
println(evens)  // [2, 4]
println(odds)   // [1, 3, 5]
val chunked = nums.chunked(2)
println(chunked)  // [[1, 2], [3, 4], [5]]
val windowed = nums.windowed(3)
println(windowed)  // [[1,2,3], [2,3,4], [3,4,5]]
09

Coroutines & Async

Launch (Fire & Forget)

launch starts a coroutine that doesn't return a result (fire-and-forget). delay is non-blocking (unlike Thread.sleep). runBlocking bridges sync/async code (use in main/tests). Coroutines are lightweight—thousands can run on a few threads.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    launch {
        delay(1000)
        println("World!")  // after 1s
    }
    println("Hello")  // immediately
    // launch doesn't return a result
    // Use for side-effect coroutines
}
// Output: Hello, then World! after 1s

Async & Await

async starts a coroutine that returns a Deferred<T>. await() suspends until the result is ready. Start multiple asyncs before awaiting for parallelism. Use for concurrent computations that produce results. Like Promise/Future in other languages.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        42  // return value
    }
    val result = deferred.await()
    println(result)  // 42
    // Parallel execution
    val a = async { computeA() }
    val b = async { computeB() }
    println(a.await() + b.await())  // concurrent
}

Suspend Functions

suspend functions can pause and resume without blocking threads. They can only be called from coroutines or other suspend functions. coroutineScope provides a structured scope (waits for all children). Use for async APIs—makes async code look synchronous.

kotlin
import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String {
    delay(500)  // simulates network
    return "User $id"
}
suspend fun fetchAll(): List<String> = coroutineScope {
    val a = async { fetchUser(1) }
    val b = async { fetchUser(2) }
    listOf(a.await(), b.await())
}
// suspend functions can only be called from coroutines

Flow (Cold Stream)

Flow is Kotlin's cold async stream (like RxJava Observable). Values are produced on collection. Use map/filter/reduce operators. emit produces, collect consumes. Ideal for streaming data, events, or paginated APIs. Hot streams use SharedFlow/StateFlow.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun numbers(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100)
        emit(i)  // produce value
    }
}
fun main() = runBlocking {
    numbers().collect { println(it) }  // 1,2,3,4,5
    // Transform
    numbers().map { it * 2 }.filter { it > 4 }.collect {
        println(it)  // 6, 8, 10
    }
}

Dispatchers & Context

Dispatchers choose the thread pool: Main for UI, IO for network/file (large pool), Default for CPU work (cores count). withContext switches context within a coroutine. Use the right dispatcher to avoid blocking UI or starving thread pools.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    launch(Dispatchers.Main) { /* UI thread */ }
    launch(Dispatchers.IO) {
        // Network/file I/O (thread pool)
        val data = fetchData()
    }
    launch(Dispatchers.Default) {
        // CPU-intensive work
        val result = heavyCompute()
    }
    // withContext switches dispatcher
    val data = withContext(Dispatchers.IO) { readFile() }
}
10

Error Handling & I/O

Try / Catch / Finally

try/catch/finally is like Java but try is an expression returning a value. No checked exceptions—all are unchecked. Catch specific exceptions for targeted handling. Use finally for cleanup. Prefer Result or nullable returns for expected failures.

kotlin
val result = try {
    "abc".toInt()
} catch (e: NumberFormatException) {
    0  // fallback
} finally {
    println("cleanup")
}
println(result)  // 0
// try is an expression—returns a value
// Kotlin doesn't have checked exceptions

Result Type

Result<T> wraps success or failure (like Try in Scala). runCatching converts exceptions to Result. getOrNull/getOrElse for safe access. onSuccess/onFailure for callbacks. Use for expected errors instead of exceptions—cleaner functional error handling.

kotlin
fun parse(s: String): Result<Int> =
    runCatching { s.toInt() }
val r = parse("42")
println(r.getOrNull())    // 42
println(r.getOrElse { 0 }) // 42
r.onSuccess { println("OK: $it") }
    .onFailure { println("Err: ${it.message}") }
val r2 = parse("abc")
println(r2.getOrNull())   // null

Custom Exceptions

Custom exceptions extend Exception (or a subclass). Pass a message for debugging. Catch specific exceptions before generic ones (order matters). Use exceptions for truly exceptional cases; for expected failures, prefer Result or nullable returns.

kotlin
class InvalidAgeException(message: String) : Exception(message)
fun setAge(age: Int) {
    if (age < 0) throw InvalidAgeException("Age cannot be negative: $age")
}
try {
    setAge(-5)
} catch (e: InvalidAgeException) {
    println("Caught: ${e.message}")
} catch (e: Exception) {
    println("Other: ${e.message}")
}

File I/O

Kotlin uses Java's File with extension functions. writeText/readText for simple cases. useLines streams lines (auto-closes, memory-efficient). For large files, use bufferedReader(). Always close resources—use use { } block for auto-closing.

kotlin
import java.io.File
// Write
File("test.txt").writeText("Hello, File!")
// Read
val content = File("test.txt").readText()
println(content)  // Hello, File!
// Append
File("log.txt").appendText("new line\n")
// Line by line
File("test.txt").useLines { lines ->
    lines.forEach { println(it) }
}

JSON (kotlinx.serialization)

kotlinx.serialization is Kotlin's official JSON library—compile-time safe, no reflection. Annotate data classes with @Serializable. encodeToString/decodeFromString for JSON. Use @SerialName for custom key names, @Optional for defaults. Alternative: Gson/Moshi.

kotlin
import kotlinx.serialization.*
import kotlinx.serialization.json.*
@Serializable
data class User(val name: String, val age: Int)
val user = User("Alice", 30)
val json = Json.encodeToString(user)
println(json)  // {"name":"Alice","age":30}
val decoded = Json.decodeFromString<User>(json)
println(decoded.name)  // Alice
// Requires @Serializable annotation
11

Coroutines Deep Dive

Structured Concurrency

Structured concurrency ties child coroutines to a parent scope—the parent won't complete until all children do, and a child failure cancels siblings. Prefer coroutineScope over GlobalScope. This prevents coroutine leaks and makes cancellation predictable.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    // coroutineScope waits for all children
    coroutineScope {
        launch { delay(1000); println("A") }
        launch { delay(500); println("B") }
    }
    println("Done")  // after both complete
    // If a child fails, parent cancels siblings
    // Never use GlobalScope unless necessary
}

Cancellation & Cooperative Cancellation

Cancellation is cooperative—coroutines must check for it at suspension points (delay, yield, await). Cancellable suspending functions throw CancellationException. Use ensureActive() or isActive to check. Non-suspending CPU loops won't respond to cancellation unless you call yield() or check isActive.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    val job = launch {
        repeat(10) { i ->
            delay(300)
            yield()  // explicit suspension point
            println("Working $i")
        }
    }
    delay(700)
    job.cancelAndJoin()  // cancels and waits
    println("Cancelled")
}
// delay/yield check for cancellation
// isActive checks cancellation state

Exception Handling

Unhandled exceptions in launch propagate to the parent (cancelling siblings) unless a CoroutineExceptionHandler is installed. async defers exceptions to await(). Use try/catch around await. The handler only works for uncaught exceptions in launch—use SupervisorJob to isolate failures.

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, e ->
        println("Caught: $e")
    }
    val job = launch(handler) {
        throw RuntimeException("Boom!")
    }
    job.join()
    // async exceptions surface on await()
    val deferred = async { throw IllegalStateException() }
    try { deferred.await() } catch (e: Exception) {
        println("Async failed: $e")
    }
}

Supervision (supervisorScope)

supervisorScope (and SupervisorJob) creates a scope where a child's failure does NOT cancel its siblings—each child fails independently. Use it for independent operations (e.g., multiple independent API calls). Regular coroutineScope cancels siblings on first failure (fail-fast).

kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
    // supervisorScope: child failures don't cancel siblings
    supervisorScope {
        launch { delay(100); throw RuntimeException("A fails") }
        launch { delay(200); println("B still runs") }
    }
    // vs coroutineScope: A's failure cancels B
    // Use SupervisorJob for long-lived services
    val scope = CoroutineScope(SupervisorJob())
}

Channels (CSP-style)

Channels pass values between coroutines (like Go channels). Rendezvous (capacity 0) synchronizes sender and receiver; buffered allows queuing. send suspends when full, receive when empty. Always close() producers. For fan-out, use BroadcastChannel or SharedFlow. Prefer Flow for most streaming needs.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
fun main() = runBlocking {
    val channel = Channel<Int>(3)  // buffered capacity 3
    launch {
        for (i in 1..5) {
            channel.send(i)
            println("Sent $i")
        }
        channel.close()
    }
    // receive() blocks until available
    for (x in channel) println("Got $x")
    // Produce-consume pattern
    // Rendezvous (cap 0) syncs sender/receiver
}
12

Flow Deep Dive

Flow Operators

Flow operators are cold—they execute only when collected. map/filter/take are like Stream/Sequence. transform is the most flexible (can emit multiple values per input). flatMap variants handle nested flows: concat (sequential), merge (concurrent), latest (cancel previous). Choose based on ordering/concurrency needs.

kotlin
import kotlinx.coroutines.flow.*
fun nums() = flow {
    for (i in 1..5) emit(i)
}
suspend fun main() {
    nums().map { it * it }           // 1,4,9,16,25
        .filter { it > 5 }          // 9,16,25
        .take(2)                    // 9,16
        .collect { println(it) }
    // transform: emit multiple values
    nums().transform { x ->
        emit(x)
        emit(x * 10)
    }.collect { println(it) }
    // flatMapConcat / flatMapMerge / flatMapLatest
}

Buffer & Concurrency

buffer decouples producer and consumer with a fixed-capacity queue—useful when production and consumption speeds differ. conflate keeps only the latest value (drop intermediates) for UI/state updates. collectLatest cancels the previous collector when a new value arrives—ideal for search-as-you-type.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun events() = flow {
    for (i in 1..3) {
        delay(100); emit(i)
    }
}
fun main() = runBlocking {
    // Without buffer: producer & consumer alternate (slow)
    events().buffer()  // decouple producer/consumer
        .collect { delay(200); println(it) }
    // conflate: drop intermediate values
    events().conflate().collect { println(it) }
    // collectLatest: cancel previous collection
    events().collectLatest { delay(200); println(it) }
}

StateFlow & SharedFlow (Hot Streams)

StateFlow is a hot stream holding a single up-to-date value—use it for UI state (replaces LiveData). It always has a value and conflate-s. SharedFlow is a hot broadcast stream (multiple collectors, no required initial value)—use it for events. SharedFlow with replay=1 behaves like a cached event bus.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
    // StateFlow: holds one value, stateful
    val state = MutableStateFlow(0)
    launch {
        state.collect { println("State: $it") }
    }
    state.value = 1  // update synchronously
    state.value = 2
    // SharedFlow: broadcasts to multiple collectors
    val events = MutableSharedFlow<String>()
    launch { events.collect { println("A: $it") } }
    launch { events.collect { println("B: $it") } }
    events.emit("Hello")  // both receive
}

Flow Exception Handling

catch handles upstream exceptions and can emit fallback values. It only catches exceptions emitted before it in the chain. retry/retryWhen re-collect the flow on failure (useful for network calls). For downstream exceptions, wrap collect in try/catch. Never catch inside the flow builder—let it propagate.

kotlin
import kotlinx.coroutines.flow.*
fun numbers() = flow {
    emit(1)
    throw RuntimeException("fail")
    emit(2)  // never reached
}
suspend fun main() {
    // catch operator (upstream only)
    numbers().catch { e -> emit(-1) }
        .collect { println(it) }  // 1, -1
    // retry on failure
    numbers().retry(3) { e ->
        println("Retry: $e"); true
    }.collect { println(it) }
    // retryWhen for custom logic
}

flowOn & Context

flowOn switches the dispatcher for the upstream flow (producer + operators above it). This is essential when the flow does blocking I/O—wrap it with flowOn(Dispatchers.IO). The downstream (collect) runs on the caller's context. Multiple flowOn calls create separate contexts for each segment.

kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun diskFlow() = flow {
    for (i in 1..3) {
        Thread.sleep(100)  // blocking I/O
        emit(i)
    }
}
suspend fun main() {
    // flowOn changes upstream context
    diskFlow().flowOn(Dispatchers.IO)
        .collect { println(it) }
    // Without flowOn, runs on collector's dispatcher
    // flowOn applies to all operators above it
    diskFlow().map { it * 2 }
        .flowOn(Dispatchers.IO)
        .filter { it > 2 }
        .collect { println(it) }
}
13

Sealed Classes & ADTs

Sealed Classes Basics

Sealed classes restrict subclasses to a known set (same file/module). The compiler knows all possible types, enabling exhaustive when expressions without else. Ideal for modeling finite states (Result, UiState, network responses). Combined with data classes, they form Algebraic Data Types.

kotlin
sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: String) : Result<Nothing>()
    object Loading : Result<Nothing>()
}
fun handle(r: Result<Int>) = when (r) {
    is Result.Success -> println("Got ${r.value}")
    is Result.Failure -> println("Error: ${r.error}")
    Result.Loading -> println("Loading...")
}
// All subclasses defined in same file/module

Sealed Interfaces (Kotlin 1.5+)

Sealed interfaces (Kotlin 1.5+) extend sealed to interfaces, allowing a class to implement multiple sealed types—more flexible than sealed classes. Same-file/same-module restriction applies. Useful for domain modeling where a type belongs to multiple categories.

kotlin
sealed interface Shape {
    fun area(): Double
}
data class Circle(val r: Double) : Shape {
    override fun area() = Math.PI * r * r
}
data class Square(val side: Double) : Shape {
    override fun area() = side * side
}
// Sealed interfaces allow multiple inheritance
sealed interface Clickable { fun click() }
class Button : Shape, Clickable {
    override fun area() = 0.0
    override fun click() = println("Clicked")
}

Exhaustive When

When used as an expression (not statement), the compiler requires all sealed cases—no else needed. Adding a new subclass produces compile errors at unhandled when sites, making refactoring safe. This is the killer feature of sealed classes for state machines and UI rendering.

kotlin
sealed class NetworkState {
    object Loading : NetworkState()
    data class Success(val data: String) : NetworkState()
    data class Error(val message: String) : NetworkState()
}
fun render(state: NetworkState): String = when (state) {
    NetworkState.Loading -> "Spinner"
    is NetworkState.Success -> "Data: ${state.data}"
    is NetworkState.Error -> "Error: ${state.message}"
    // No else needed—compiler enforces all cases
}
// Adding a new subclass causes compile error
// until you handle it

ADT Modeling (Result/Either)

Sealed classes model sum types (Either/Result) for railway-oriented programming. fold handles both branches. Unlike exceptions, errors are explicit in the type signature. Arrow-kt provides richer Either/Validated. Use for predictable, composable error handling without try/catch.

kotlin
sealed class Either<out L, out R> {
    data class Left<out L>(val value: L) : Either<L, Nothing>()
    data class Right<out R>(val value: R) : Either<Nothing, R>()
}
fun <L, R> Either<L, R>.fold(
    ifLeft: (L) -> Unit,
    ifRight: (R) -> Unit
) = when (this) {
    is Either.Left -> ifLeft(value)
    is Either.Right -> ifRight(value)
}
fun divide(a: Int, b: Int): Either<String, Int> =
    if (b == 0) Either.Left("Divide by zero")
    else Either.Right(a / b)

Sealed Classes with Recursion

Sealed classes can be recursive, modeling tree structures (ASTs, JSON, expressions). Pattern matching with when + recursion evaluates them elegantly. This is classic functional ADT usage—type-safe, exhaustive, and refactor-friendly. Used in compilers, parsers, and config DSLs.

kotlin
sealed class Expr {
    data class Num(val value: Int) : Expr()
    data class Add(val left: Expr, val right: Expr) : Expr()
    data class Mul(val left: Expr, val right: Expr) : Expr()
}
fun eval(e: Expr): Int = when (e) {
    is Expr.Num -> e.value
    is Expr.Add -> eval(e.left) + eval(e.right)
    is Expr.Mul -> eval(e.left) * eval(e.right)
}
val expr = Expr.Add(Expr.Num(2), Expr.Mul(Expr.Num(3), Expr.Num(4)))
fun main() = println(eval(expr))  // 14
14

Delegated Properties

lazy Delegate

lazy defers initialization until first access and caches the result. Default is thread-safe (double-checked locking). Use for expensive resources (configs, DB connections, singletons) that may not be needed. Pass NONE mode for single-threaded contexts to avoid synchronization overhead.

kotlin
val heavyConfig: Config by lazy {
    println("Initializing...")
    loadConfigFromFile()  // runs once, first access
}
fun main() {
    println("Before access")
    println(heavyConfig)  // initializes here
    println(heavyConfig)  // cached, no re-init
}
// lazy is thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED)
// Use LazyThreadSafetyMode.NONE for single-threaded

observable & vetoable

observable fires a callback after each change (logging, side effects). vetoable can reject changes by returning false (validation). Both take an initial value and a lambda. Use for reactive state, validation, or triggering UI updates. For more complex scenarios, use custom delegates.

kotlin
import kotlin.properties.Delegates
var count: Int by Delegates.observable(0) { _, old, new ->
    println("Changed: $old -> $new")
}
var age: Int by Delegates.vetoable(0) { _, old, new ->
    new >= 0  // reject negative values
}
fun main() {
    count = 1   // prints "Changed: 0 -> 1"
    count = 2   // prints "Changed: 1 -> 2"
    age = 25    // accepted
    age = -5    // rejected, stays 25
}

Custom Property Delegate

Custom delegates implement getValue (and setValue for vars) via ReadOnlyProperty/ReadWriteProperty. They encapsulate reusable property behavior (validation, formatting, caching, DB mapping). The thisRef is the owner, prop is the property metadata. Powerful for ORM/serialization frameworks.

kotlin
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimmedString : ReadWriteProperty<Any?, String> {
    private var value: String = ""
    override fun getValue(thisRef: Any?, prop: KProperty<*>) = value
    override fun setValue(thisRef: Any?, prop: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}
class User {
    var name: String by TrimmedString()
}
fun main() {
    val u = User()
    u.name = "  Alice  "
    println(u.name)  // "Alice"
}

Map-backed Properties

Delegating to a Map lets you bind properties to dynamic keys—useful for parsing JSON, configs, or ORM rows without boilerplate. Property name must match the map key. Use MutableMap for writable properties. This is how kotlinx.serialization and many ORMs work under the hood.

kotlin
class User(map: Map<String, Any?>) {
    val name: String by map
    val age: Int by map
    val email: String? by map
}
fun main() {
    val u = User(mapOf(
        "name" to "Alice",
        "age" to 30,
        "email" to "[email protected]"
    ))
    println(u.name)  // Alice
}
// Mutable version: MutableMap
class MutableUser(map: MutableMap<String, Any?>) {
    var name: String by map
}

notNull & Singleton Delegate

notNull() is like lateinit but for any type (including primitives) and works with val/var. It throws if accessed before set. For singletons, prefer object (eager) or by lazy (lazy). lateinit is for vars in classes only; notNull delegate is more flexible but has slight overhead.

kotlin
import kotlin.properties.Delegates
class Service {
    // Late-init for non-null, set once
    var config: String by Delegates.notNull()
    // Throws before initialization
}
// Singleton via object (no delegate needed)
object Database {
    val connection = connect()
}
// Or lazy singleton
val db: Database by lazy { Database() }
fun main() {
    val s = Service()
    // println(s.config)  // IllegalStateException
    s.config = "prod"
    println(s.config)  // prod
}
15

DSL Building

Type-Safe Builders

Type-safe builders use function types with receiver (lambda with receiver) to create nested DSLs. The lambda runs in the context of the receiver (this), so you call its methods directly. This is how Kotlin's HTML DSL, Gradle build scripts, and kotlinx.html work—declarative and compile-time checked.

kotlin
class Table {
    private val rows = mutableListOf<Row>()
    fun row(init: Row.() -> Unit) {
        val r = Row(); r.init(); rows.add(r)
    }
    fun build() = rows
}
class Row {
    private val cells = mutableListOf<String>()
    fun cell(text: String) { cells.add(text) }
    fun build() = cells
}
fun table(init: Table.() -> Unit) = Table().apply(init).build()
val t = table {
    row { cell("A"); cell("B") }
    row { cell("C"); cell("D") }
}

@DslMarker (Scope Control)

@DslMarker prevents implicit receiver leakage—inside a nested DSL block, you can only call methods of the innermost receiver. Without it, Kotlin would allow calling outer receivers' methods, leading to confusing/buggy DSLs. Annotate all DSL classes with the same marker annotation.

kotlin
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML { fun body(b: Body.() -> Unit) { /*...*/ } }
@HtmlDsl
class Body { fun p(text: String) { /*...*/ } }
fun html(init: HTML.() -> Unit) = HTML().apply(init)
// Without @DslMarker, inner 'this' could call outer methods
html {
    body {
        // p() is unambiguous—Body's method
        // body() would be an error (not Body's method)
        p("Hello")
    }
}

Infix Functions

infix functions enable natural-language syntax (a to b, 1 until 10). They must be member or extension functions with a single parameter. Used heavily in DSLs, testing frameworks, and math libraries. Built-in examples: to (Pair), until/step (ranges), and collection operations.

kotlin
infix fun Int.times(str: String) = str.repeat(this)
fun main() {
    println(3 times "ab")  // "ababab"
    // Equivalent to: 3.times("ab")
    // No dot, no parentheses—reads like natural language
}
// Common in testing
infix fun <T> T.shouldEqual(expected: T) =
    assert(this == expected)
fun test() { 5 shouldEqual 5 }
// Also: to, until, step, in, etc.

Operator Overloading

Operator overloading lets objects use +, -, *, [], (), etc. with natural syntax. Mark functions with operator. Overload sensibly—math types (Vec, Matrix, Money) benefit; arbitrary overloading hurts readability. get/set enable indexing; invoke makes objects callable. Range operators (rangeTo, contains) power for-loops.

kotlin
data class Vec(val x: Int, val y: Int) {
    operator fun plus(o: Vec) = Vec(x + o.x, y + o.y)
    operator fun minus(o: Vec) = Vec(x - o.x, y - o.y)
    operator fun times(s: Int) = Vec(x * s, y * s)
    operator fun unaryMinus() = Vec(-x, -y)
    operator fun get(i: Int) = when (i) { 0 -> x; 1 -> y; else -> throw IndexOutOfBoundsException() }
}
fun main() {
    val a = Vec(1, 2)
    val b = Vec(3, 4)
    println(a + b)        // Vec(x=4, y=6)
    println(a * 2)        // Vec(x=2, y=4)
    println(-a)           // Vec(x=-1, y=-2)
    println(a[0])         // 1
}

Function Types with Receiver

Function types with receiver (A.(B) -> C) let lambdas access the receiver as 'this'. This is the foundation of Kotlin DSLs and scope functions (apply, run, with). apply returns the receiver; run returns the lambda result. Master this to build expressive, type-safe internal DSLs.

kotlin
// Lambda with receiver: A.(B) -> C
val greet: String.(Int) -> String = { times -> repeat(times) { this + "!" } }
fun main() {
    println("Hello".greet(3))  // Hello!!!Hello!!!Hello!!!
    // 'this' is the String receiver
}
// Build DSLs with receiver
class StringBuilder {
    private val parts = mutableListOf<String>()
    fun add(s: String) { parts.add(s) }
}
fun build(init: StringBuilder.() -> Unit) =
    StringBuilder().apply(init)
val sb = build { add("a"); add("b") }
16

Testing (JUnit 5, MockK, Turbine)

JUnit 5 Basics

JUnit 5 (Jupiter) is the standard Kotlin testing framework. @Test marks tests; @BeforeEach/@AfterEach run per-test setup/teardown. Use backtick names for readability. @ParameterizedTest + @ValueSource run a test with multiple inputs. assertThrows checks exceptions. @Disabled skips tests.

kotlin
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
class CalculatorTest {
    lateinit var calc: Calculator
    @BeforeEach fun setup() { calc = Calculator() }
    @Test fun `add two numbers`() {
        assertEquals(5, calc.add(2, 3))
    }
    @Test fun `divide by zero throws`() {
        assertThrows<ArithmeticException> { calc.divide(10, 0) }
    }
    @ParameterizedTest
    @ValueSource(ints = [1, 2, 3])
    fun `positive numbers`(n: Int) { assertTrue(n > 0) }
    @Disabled("TODO") @Test fun skip() {}
}

MockK (Mocking)

MockK is Kotlin's idiomatic mocking library (handles final classes, coroutines, extension functions). every { } returns/throws stubs; verify { } checks calls. Use relaxed = true to skip stubbing void methods. MockK supports suspend functions natively via coEvery/coVerify—essential for coroutine testing.

kotlin
import io.mockk.*
interface UserService { fun find(id: Int): String?; fun save(name: String): Int }
class Test {
    val mock = mockk<UserService>()
    @Test fun test() {
        every { mock.find(1) } returns "Alice"
        every { mock.find(any()) } returns null
        every { mock.save(any()) } throws RuntimeException("fail")
        verify { mock.find(1) }              // called once
        verify(exactly = 2) { mock.find(any()) }
        confirmVerified(mock)
    }
}
// relaxUnitFun / relaxed = true for void methods

Turbine (Flow Testing)

Turbine is the standard library for testing Flow. test { } collects in a controlled scope; awaitItem() asserts the next emission, awaitComplete()/awaitError() check termination. It handles timing and virtual time correctly. Combine with runTest for coroutine-friendly, fast, deterministic Flow tests.

kotlin
import app.cash.turbine.test
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import kotlin.test.*
class FlowTest {
    @Test fun testFlow() = runTest {
        flowOf(1, 2, 3).test {
            assertEquals(1, awaitItem())
            assertEquals(2, awaitItem())
            assertEquals(3, awaitItem())
            awaitComplete()
        }
    }
    @Test fun testError() = runTest {
        flow<Int> { throw RuntimeException("x") }.test {
            assertTrue(awaitError() is RuntimeException)
        }
    }
}

Coroutines Test (runTest)

runTest uses virtual time—delays skip instantly, making async tests fast and deterministic. Use StandardTestDispatcher + advanceUntilIdle() for manual control. Inject a TestDispatcher into production code (instead of Dispatchers.Main) for testability. Avoid runBlocking in tests—runTest is the modern way.

kotlin
import kotlinx.coroutines.test.*
import kotlinx.coroutines.*
import kotlin.test.*
class RepoTest {
    @Test fun fetch() = runTest {
        // Virtual time: delay(1000) completes instantly
        val result = async { delay(1000); "data" }.await()
        assertEquals("data", result)
    }
    @Test fun withDispatcher() = runTest {
        val scheduler = StandardTestDispatcher()
        val scope = CoroutineScope(scheduler)
        // advanceUntilIdle() runs pending coroutines
        scope.launch { /*...*/ }
        scheduler.advanceUntilIdle()
    }
}

Kotest Assertions & Styles

Kotest offers expressive assertions (shouldBe, shouldContain) and multiple spec styles (StringSpec, FunSpec, BehaviorSpec for BDD). It integrates with property-based testing and has rich matchers. Choose it over JUnit for Kotlin-idiomatic, readable tests. It can run alongside JUnit 5.

kotlin
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.string.shouldStartWith
class MyTest : StringSpec({
    "addition works" {
        1 + 1 shouldBe 2
    }
    "list contains" {
        listOf(1, 2, 3) shouldContain 2
    }
    "string prefix" {
        "hello" shouldStartWith "he"
    }
})
// Other styles: FunSpec, BehaviorSpec, ShouldSpec
17

Functional Programming Deep Dive

Scope Functions (let, run, with, apply, also)

Scope functions differ by receiver (this vs it) and return value (this vs result). apply/also return the receiver (chaining, builders); let/run/with return the lambda result (transforms). Use apply for configuration, let for null-checks/transforms, also for side effects. Don't overuse—readability first.

kotlin
data class User(var name: String, var age: Int)
fun main() {
    val u = User("A", 1)
    // apply: configure & return this (builders)
    val configured = u.apply { age = 30 }
    // let: transform, it = receiver, returns lambda result
    val name = u.let { it.name.uppercase() }
    // run: transform, this = receiver, returns result
    val s = u.run { "$name:$age" }
    // with: like run but called as function
    val str = with(u) { "$name:$age" }
    // also: side effects, returns this (logging/chaining)
    u.also { println("Created $it") }
}

Sequences (Lazy Evaluation)

Sequences evaluate lazily—each element flows through the whole pipeline before the next starts (like Java Streams). This avoids intermediate collections and short-circuits (take/find). Use for large datasets or multi-step pipelines. For small lists, eager List is often faster (less overhead). generateSequence builds infinite streams.

kotlin
fun main() {
    // Eager: List processes each step fully
    val eager = (1..10).toList()
        .map { it * 2 }
        .filter { it > 5 }
    // Lazy: Sequence processes element-by-element
    val lazy = (1..10).asSequence()
        .map { println("map $it"); it * 2 }
        .filter { println("filter $it"); it > 5 }
        .take(2)
        .toList()  // only processes until 2 found
    // Use Sequence for large/pipelined data
    // generateSequence for infinite streams
    val naturals = generateSequence(1) { it + 1 }
}

Inline Functions & reified

inline eliminates lambda object/allocation overhead by inlining bytecode—critical for hot loops. It also enables non-local returns (return from outer function inside a lambda). reified type parameters make generic type info available at runtime (T::class, is T), but require inline. Use for type-safe helpers like filterIsInstance.

kotlin
// inline: copies bytecode at call site (no lambda overhead)
inline fun measure(block: () -> Unit): Long {
    val start = System.currentTimeMillis()
    block()
    return System.currentTimeMillis() - start
}
// non-local return: inline lambdas can return from outer fun
inline fun forEach(list: List<Int>, f: (Int) -> Unit) {
    for (i in list) f(i)
}
// reified: access generic type at runtime
inline fun <reified T> List<*>.filterIsInstance() =
    filter { it is T } as List<T>
val strs = listOf(1, "a", 2, "b").filterIsInstance<String>()

Higher-Order Functions

Higher-order functions take or return functions. They're the backbone of functional Kotlin—map, filter, fold, compose. Function types like (Int) -> Int are first-class. compose builds pipelines. Use them to abstract patterns, enable code reuse, and write declarative code. Mark inline for performance-critical paths.

kotlin
fun <T, R> List<T>.mapTo(transform: (T) -> R): List<R> {
    val result = mutableListOf<R>()
    for (item in this) result.add(transform(item))
    return result
}
fun compose(f: (Int) -> Int, g: (Int) -> Int): (Int) -> Int =
    { x -> f(g(x)) }
fun main() {
    val double = { x: Int -> x * 2 }
    val inc = { x: Int -> x + 1 }
    val doubleThenInc = compose(inc, double)
    println(doubleThenInc(3))  // 7
    listOf(1, 2, 3).mapTo { it * it }
}

Recursion & tailrec

tailrec converts tail-recursive functions (where recursion is the last operation) into loops, preventing stack overflow. The recursive call must be in tail position—no pending multiplication/addition. Use an accumulator parameter to make functions tail-recursive. Essential for functional-style loops on large inputs.

kotlin
// Regular recursion: stack overflow on large n
fun factorial(n: Int): Long =
    if (n <= 1) 1 else n * factorial(n - 1)
// tailrec: compiler optimizes to a loop (no stack growth)
tailrec fun factorialTail(n: Int, acc: Long = 1): Long =
    if (n <= 1) acc else factorialTail(n - 1, acc * n)
// Fibonacci with tailrec
tailrec fun fib(n: Int, a: Long = 0, b: Long = 1): Long =
    when (n) { 0 -> a; 1 -> b; else -> fib(n - 1, b, a + b) }
fun main() {
    println(factorialTail(10000))  // no stack overflow
}
18

Generics & Variance

Generic Classes & Functions

Generics enable type-safe, reusable code. Classes use <T>; functions declare <T> before the return type. Constraints (T : Entity) restrict the type bound. Unlike Java, Kotlin's generics are reified for inline functions and have declaration-site variance, making generic APIs safer and more ergonomic.

kotlin
class Box<T>(val value: T) {
    fun get(): T = value
}
fun <T> singletonList(item: T): List<T> = listOf(item)
fun main() {
    val intBox = Box(42)        // Box<Int> inferred
    val strBox = Box<String>("hi")
    val nums = singletonList(1)
}
// Generic constraints
class Repository<T : Entity> {
    fun save(item: T) { /* T is Entity subtype */ }
}
interface Entity { val id: Int }

Variance (in/out)

Variance controls subtype relationships of generics. out (covariant): a Producer<Dog> is a Producer<Animal>—safe because you only read T. in (contravariant): a Sink<Animal> is a Sink<Dog>—safe because you only write T. Mutable collections are invariant (read + write). Use out/in to design safe, flexible APIs.

kotlin
// out (covariant): Producer<Sub> is Producer<Super>
interface Source<out T> { fun next(): T }
// in (contravariant): Consumer<Super> is Consumer<Sub>
interface Sink<in T> { fun put(item: T) }
// invariant (default): neither
class MutableList<T> {
    fun add(item: T) {}
    fun get(): T = TODO()
}
open class Animal
class Dog : Animal()
val src: Source<Animal> = Source<Dog>()  // OK (out)
val sink: Sink<Dog> = Sink<Animal>()     // OK (in)

Variance in Practice

Function types use variance automatically: parameters are 'in', return types are 'out'. This is why (Dog) -> Unit is assignable to (Animal) -> Unit. The PECS principle (Producer Extends, Consumer Super) maps to Kotlin's out/in. Design generic interfaces with out when they only produce T, in when they only consume T.

kotlin
// Function types are variant by nature
// (T) -> R is contravariant in T, covariant in R
val dogHandler: (Dog) -> Unit = { println(it) }
val animalHandler: (Animal) -> Unit = dogHandler  // OK
// Covariant return types
interface Repository<out T> { fun find(id: Int): T }
class DogRepo : Repository<Dog> {
    override fun find(id: Int): Dog = Dog()
}
val repo: Repository<Animal> = DogRepo()  // OK
// PECS rule: Producer extends, Consumer super
// Kotlin: out = producer, in = consumer

Type Projections & Star Projection

Type projections temporarily make a type variant at a use-site. Array<out Any> means 'Array of some subtype of Any, readable'. Array<in Any> means 'writable as Any'. Star projection (*) means 'some unknown type'—useful when you only need size/contains, not the element type. Like Java's wildcards.

kotlin
fun copy(from: Array<out Any>, to: Array<in Any>) {
    // from: only read (covariant projection)
    // to: only write (contravariant projection)
    for (i in from.indices) to[i] = from[i]
}
// Star projection: unknown type, read-only
fun printSize(list: List<*>) {
    println(list.size)  // OK (no type needed)
    // list[0]  // type is Any? — limited use
}
val mixed: List<*> = listOf(1, "a", 3.0)
// Use when type is irrelevant or unknown

Reified Type Parameters

reified preserves generic type info at runtime (normally erased on JVM). It requires inline (the type is known at each call site). Enables is T checks, T::class, and filterIsInstance. Without reified, you'd pass a Class<T> parameter manually. Limitation: reified types can't be used in non-inline functions or as class type parameters.

kotlin
// reified requires inline
inline fun <reified T> List<*>.filterIsInstance(): List<T> =
    filter { it is T } as List<T>
inline fun <reified T> Any.castTo(): T = this as T
inline fun <reified T> typeOf() = T::class.simpleName
fun main() {
    val list = listOf(1, "a", 2, "b", 3)
    val strs: List<String> = list.filterIsInstance()
    println(strs)  // [a, b]
    println(typeOf<String>())  // String
    // Without reified, T is erased at runtime
    // reified makes T::class and 'is T' available
}
19

Null Safety Deep

Nullable Types

Kotlin distinguishes nullable (T?) and non-null (T) types at compile time. ?. safe call returns null if receiver is null. ?: Elvis provides a default. !! throws NPE (avoid). The compiler enforces null checks, eliminating NullPointerException in Kotlin code.

kotlin
var name: String = "Alice"  // Non-null
// name = null  // Error
var nickname: String? = null  // Nullable
// Safe call
println(nickname?.length)  // null if nickname is null
// Elvis operator
val len = nickname?.length ?: 0  // 0 if null
// Not-null assertion (use sparingly)
val len2 = nickname!!.length  // NPE if null

let & run

let executes a block if the value is non-null (it = the value). run calls a block with the object as receiver, returns result. apply configures an object, returns the object. also performs side effects, returns the object. These scope functions reduce null checks and improve readability.

kotlin
val name: String? = "Alice"
name?.let {
    println("Length: ${it.length}")  // Only if not null
}
// run: object + block
val result = "Hello".run {
    length  // Returns 5
}
// apply: configure object
val list = mutableListOf<Int>().apply {
    add(1); add(2); add(3)
}

lateinit

lateinit defers initialization of non-null properties. Cannot be used with primitives or nullable types. Throws UninitializedPropertyAccessException if accessed before init. ::prop.isInitialized checks. Useful for dependency injection and lifecycle-managed properties.

kotlin
class Service {
    lateinit var dependency: Database
    fun init() {
        dependency = connectDatabase()
    }
    fun use() {
        if (::dependency.isInitialized) {
            dependency.query()
        }
    }
}

Nullable Collections

Collections can hold nullable elements (List<String?>) or be nullable (List<String>?). filterNotNull removes nulls. firstOrNull returns null instead of throwing. Handle nullable elements with safe calls. Be explicit about nullability in collection types.

kotlin
val list: List<String?> = listOf("a", null, "b")
val filtered = list.filterNotNull()  // ["a", "b"]
val lengths = list.map { it?.length }  // [1, null, 1]
val first = list.firstOrNull()  // "a" or null
val firstNonEmpty = list.firstOrNull { it?.isNotEmpty() == true }

Platform Types

Platform types arise from Java interop where nullability is unknown. Kotlin cannot enforce null safety for them. Always declare nullable types explicitly for Java return values. Use @Nullable/@NotNull annotations in Java. JSR-305 annotations help Kotlin infer nullability.

kotlin
// Java interop: platform type (no null info)
val name: String = javaObject.getName()  // May be null!
// Kotlin does not know if Java returns null
// Fix: explicit nullable type
val name2: String? = javaObject.getName()
// Or @Nullable annotation in Java
20

DSL Construction

Builder with Lambda Receiver

Lambda with receiver (T.() -> Unit) enables DSL syntax. Inside the lambda, this is the receiver object. Methods can be called without qualification. This is how Kotlin builds type-safe DSLs like Gradle, HTML, and SQL builders.

kotlin
class StringBuilder {
    private val parts = mutableListOf<String>()
    fun line(s: String) { parts.add(s) }
    fun build() = parts.joinToString("\n")
}
fun buildString(init: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.init()  // Execute lambda with sb as receiver
    return sb.build()
}

HTML DSL

HTML DSL uses nested builders with lambda receivers. Each tag is a function that creates a child builder. The lambda configures the child. Produces type-safe, composable HTML. kotlinx.html is a real implementation. Same pattern works for any hierarchical structure.

kotlin
fun html(init: HTML.() -> Unit): HTML {
    val h = HTML(); h.init(); return h
}
class HTML {
    fun body(init: Body.() -> Unit) { /* ... */ }
}
html {
    body {
        // this: Body
        p("Hello")
    }
}

@DslMarker

@DslMarker prevents implicit receiver access to outer scopes. Without it, both HTML and Body methods are accessible, causing confusion. The annotation restricts access to the innermost receiver. Makes DSLs type-safe and unambiguous. Essential for complex DSLs.

kotlin
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML { fun body(...) {} }
@HtmlDsl
class Body { fun p(...) {} }
html {
    body {
        // p()  // OK: in Body scope
        // body()  // Error: in Body scope, not HTML
    }
}

Gradle DSL

Gradle Kotlin DSL uses the same builder pattern. plugins, dependencies are functions with receiver lambdas. implementation, testImplementation are dependency configuration functions. Type-safe: compiler checks function names and parameter types. Much better than Groovy for refactoring.

kotlin
plugins {
    kotlin("jvm") version "1.9.0"
}
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    testImplementation(kotlin("test"))
}
// All are function calls with lambda receivers

Anko SQL

Anko (and Exposed) provide type-safe SQL DSLs. Column comparisons are typed. Cannot compare a string column to an int. The DSL generates SQL. Prevents SQL injection and type errors. Same pattern applies to any domain-specific language.

kotlin
fun users(where: SqlExpressionBuilder.() -> Op<Boolean>) {
    // DSL for SQL queries
}
users {
    (Users.age greater 18) and (Users.name like "A%")
}
// Type-safe SQL: compiler checks column types
21

Collections

List Operations

Kotlin collections have rich functional APIs. map, filter, reduce are standard. groupBy partitions by key. chunked splits into fixed-size lists. windowed creates sliding windows. All return new collections. Use asSequence() for lazy evaluation.

kotlin
val list = listOf(1, 2, 3, 4, 5)
val doubled = list.map { it * 2 }
val evens = list.filter { it % 2 == 0 }
val sum = list.reduce { acc, n -> acc + n }
val grouped = list.groupBy { it % 2 }  // {0=[2,4], 1=[1,3,5]}
val chunked = list.chunked(2)  // [[1,2],[3,4],[5]]

Sequence

Sequence is a lazy collection (like Java Stream). Operations are evaluated on demand. No intermediate collections. Efficient for large collections with chained operations. Use asSequence() to convert. Force with toList(), toSet(), etc.

kotlin
val result = (1..1000000).asSequence()
    .map { it * 2 }
    .filter { it > 100 }
    .take(10)
    .toList()
// Lazy: only computes what is needed
// No intermediate collections

Destructuring

Destructuring splits objects into variables. Works with Pair, Triple, data classes, and Map entries. componentN() functions enable it. withIndex() pairs index and value. Useful for multiple return values and iteration. Data classes auto-generate componentN.

kotlin
val (a, b) = Pair(1, "hello")
val (x, y, z) = Triple(1, 2, 3)
for ((index, value) in list.withIndex()) {
    println("$index: $value")
}
data class Point(val x: Int, val y: Int)
val (px, py) = Point(1, 2)

Mutable vs Immutable

Kotlin has both mutable (MutableList) and immutable (List) collections. Prefer immutable for safety. toList() creates an immutable copy. toMutableList() creates a mutable copy. The immutable interfaces do not expose mutation methods, preventing accidental modification.

kotlin
val mutable = mutableListOf(1, 2, 3)
mutable.add(4)  // OK
val immutable = listOf(1, 2, 3)
// immutable.add(4)  // Error: no add method
// Convert
val imm = mutable.toList()
val mut = imm.toMutableList()

Associate & Partition

toMap/associate convert to maps. partition splits into two lists by predicate. flatten merges nested lists. flatMap maps and flattens. These replace verbose loops with declarative expressions. All return new collections.

kotlin
val list = listOf("a" to 1, "b" to 2)
val map = list.toMap()  // {a=1, b=2}
val byLength = list.associate { it.first to it.second }
val (evens, odds) = list.partition { it.second % 2 == 0 }
// evens: [(a,1)?] odds: [(b,2)]
val flat = listOf(listOf(1), listOf(2, 3)).flatten()  // [1,2,3]
22

Common Pitfalls

== vs ===

Kotlin == calls equals (value equality), unlike Java. === checks reference equality. Use == for value comparison. === is rarely needed. For Integer, values -128 to 127 are cached, so === may be true or false. Always use == for values.

kotlin
val a = Integer(127)
val b = Integer(127)
a == b  // true (value equality, calls equals)
a === b  // true (Integer caches -128 to 127)
val c = Integer(128)
val d = Integer(128)
c == d  // true
c === d  // false (not cached)

Companion Object

Kotlin does not have static members. companion object holds "static" methods and constants. const val is a true compile-time constant. The companion object is a singleton instance. @JvmStatic makes methods callable as static from Java. Use top-level functions for true statics.

kotlin
class MyClass {
    companion object {
        const val CONSTANT = 42
        fun create() = MyClass()
    }
}
MyClass.CONSTANT  // 42
MyClass.create()  // Static-like access
// companion object is a real object, not static

Data Class Copy

Data classes auto-generate copy() which creates a modified copy. Original is unchanged (immutable). Only specified fields change. Useful for updates. Combined with destructuring, data classes are powerful for modeling data. Avoid var in data classes for immutability.

kotlin
data class User(val name: String, val age: Int)
val alice = User("Alice", 30)
val older = alice.copy(age = 31)  // New instance
// alice is unchanged
// copy uses named arguments for changed fields

Sealed Class Exhaustiveness

Sealed classes restrict subtypes to the same file/package. when on sealed classes is exhaustive: the compiler warns if a branch is missing. No else needed if all cases are covered. Adding a new subtype causes warnings everywhere. Ideal for state machines and results.

kotlin
sealed class Result
data class Success(val value: Int) : Result()
data class Failure(val error: String) : Result()
fun handle(r: Result) = when (r) {
    is Success -> r.value
    // Warning: Missing Failure branch
}
// Fix: handle all cases
fun handle(r: Result) = when (r) {
    is Success -> r.value
    is Failure -> 0
}  // No else needed

Extension vs Member

Member functions always take precedence over extension functions with the same signature. Extensions are resolved at compile time (static dispatch), members at runtime (dynamic dispatch). Extensions cannot override members. Use extensions for utility functions, not for polymorphism.

kotlin
class Foo {
    fun bar() = "member"  // Member function
}
fun Foo.bar() = "extension"  // Extension function
Foo().bar()  // "member" - members win!
// Extensions are resolved statically
// Members are resolved dynamically (virtual)
23

Kotlin Multiplatform

Common Code

Kotlin Multiplatform (KMP) shares code across platforms. expect/actual declares platform-specific implementations. commonMain has shared code. Platform-specific source sets implement actual. Share business logic, keep UI native. Gradle configures targets.

kotlin
// commonMain/src/Platform.kt
expect fun getPlatformName(): String
// androidMain/src/Platform.kt
actual fun getPlatformName(): String = "Android"
// iosMain/src/Platform.kt
actual fun getPlatformName(): String = "iOS"
// Shared business logic
class Greeting {
    fun greet() = "Hello from ${getPlatformName()}"
}

Shared Module

Multiplatform projects use kotlin block in Gradle. Define targets (android, ios). commonMain has shared dependencies. Platform source sets can have platform-specific deps. iOS uses Kotlin/Native for direct compilation. Share logic, not UI.

kotlin
// build.gradle.kts (shared module)
kotlin {
    androidTarget()
    iosX64(); iosArm64(); iosSimulatorArm64()
    sourceSets {
        val commonMain by getting {
            dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") }
        }
    }
}

Networking (Ktor)

Ktor is a multiplatform HTTP client. The API is common, the engine is platform-specific. Android uses OkHttp/Android engine. iOS uses Darwin. Coroutines work across platforms. Share networking, database (SQLDelight), and business logic.

kotlin
// Shared HTTP client
suspend fun fetchUser(): User {
    return client.get("https://api.example.com/user").body()
}
// Platform-specific engine
// Android: OkHttp or Android
// iOS: Darwin
// Common code uses the same API

SQLDelight

SQLDelight generates type-safe Kotlin from SQL. .sq files contain SQL with named queries. Generates type-safe query objects. Works across platforms (Android, iOS, JVM). SQL is the source of truth. Schema migrations are tracked. Alternative to Room for multiplatform.

kotlin
// .sq file: User.sq
CREATE TABLE User (id INTEGER, name TEXT);
selectById: SELECT * FROM User WHERE id = ?;
insertUser: INSERT INTO User (id, name) VALUES (?, ?);
// Generated Kotlin code
val queries: UserQueries = database.userQueries
queries.insertUser(1, "Alice")
val user = queries.selectById(1).executeAsOne()

Compose Multiplatform

Compose Multiplatform extends Jetpack Compose to iOS, Desktop, and Web. Share UI code across platforms. Same @Composable API. Platform-specific entry points. Still experimental for iOS. Reduces UI duplication. Gradle plugin: compose-multiplatform.

kotlin
// Shared UI with Compose Multiplatform
@Composable
fun Greeting(name: String) {
    Text("Hello, $name!")
}
// Android
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { Greeting("World") }
    }
}
// Desktop
fun main() = application {
    Window { Greeting("Desktop") }
}
24

Testing Kotlin

JUnit 5

JUnit 5 is the standard testing framework. Kotlin allows backtick test names for readability. assertEquals, assertThrows are common assertions. @BeforeEach, @AfterEach for setup/teardown. @ParameterizedTest for data-driven tests. Use kotlin.test for multiplatform.

kotlin
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class CalculatorTest {
    @Test
    fun `test addition`() {
        val calc = Calculator()
        assertEquals(5, calc.add(2, 3))
    }
    @Test
    fun `throws on negative`() {
        assertThrows<IllegalArgumentException> { calc.sqrt(-1) }
    }
}

MockK

MockK is a Kotlin-native mocking library. Supports final classes and extension functions. every stubs, verify checks calls. @MockK creates mocks, @InjectMockKs injects them. coEvery/coVerify for coroutines. Better Kotlin support than Mockito.

kotlin
class UserServiceTest {
    @MockK
    lateinit var repo: UserRepository
    @InjectMockKs
    lateinit var service: UserService
    @BeforeEach
    fun setup() {
        MockKAnnotations.init(this)
        every { repo.find(1) } returns User("Alice")
    }
    @Test
    fun `test find`() {
        assertEquals("Alice", service.find(1).name)
        verify { repo.find(1) }
    }
}

Kotest

Kotest is a Kotlin-first testing framework. Multiple styles: StringSpec, BehaviorSpec, FunSpec. shouldBe is a fluent assertion. Property testing with Arb. Supports data-driven tests. Integrates with Spring and Ktor. More Kotlin-idiomatic than JUnit.

kotlin
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class CalculatorTest : StringSpec({
    "addition should work" {
        Calculator().add(2, 3) shouldBe 5
    }
    "should throw on negative" {
        shouldThrow<IllegalArgumentException> { Calculator().sqrt(-1) }
    }
})

Coroutine Testing

runTest from kotlinx-coroutines-test provides virtual time. Delays skip instantly. advanceUntilIdle runs pending coroutines. Much faster than real time. Use TestDispatcher for fine control. Turbine library tests Flow emissions.

kotlin
@Test
fun `test async`() = runTest {
    val result = fetchData()
    assertEquals("data", result)
}
// runTest replaces runBlocking with virtual time
// advanceUntilIdle() runs all pending coroutines
// Delay skips instantly

Turbine (Flow Testing)

Turbine tests Flow emissions. awaitItem gets next emission. awaitComplete/asserts completion. awaitError asserts an error. test block suspends until the flow completes. Much cleaner than collecting manually. Essential for Flow testing.

kotlin
@Test
fun `test flow`() = runTest {
    flowOf(1, 2, 3).test {
        awaitItem() shouldBe 1
        awaitItem() shouldBe 2
        awaitItem() shouldBe 3
        awaitComplete()
    }
}
// Test errors
flow<Int> { throw Exception() }.test {
    awaitError() shouldBe Exception()
}
25

Coroutines Deep Dive

Coroutine Scope

CoroutineScope defines a lifetime for coroutines. viewModelScope auto-cancels on VM clear. SupervisorJob prevents child failures from cancelling siblings. Custom scopes need explicit cancellation. Scopes propagate cancellation to children. Never use GlobalScope in production (cannot be cancelled).

kotlin
class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch {
            val data = api.getData()
            _data.value = data
        }
    }
}
// Custom scope
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch { /* ... */ }
scope.cancel()  // Cancel all children

Dispatchers

Dispatchers route coroutines to thread pools. Main: UI thread (Android). IO: blocking I/O (64+ threads). Default: CPU work (CPU count threads). Unconfined: caller thread (advanced). withContext switches dispatcher temporarily. Choosing the right dispatcher improves performance and prevents UI freezes.

kotlin
launch(Dispatchers.Main) { updateUI() }
launch(Dispatchers.IO) { writeFile() }
launch(Dispatchers.Default) { heavyComputation() }
launch(Dispatchers.Unconfined) { runAnywhere() }
// Switch context
withContext(Dispatchers.IO) {
    val data = readDisk()
}

Flow Operators

Flow operators: map transforms, filter selects, flatMapMerge/flatMapConcat chain flows. Cold flows start fresh per collector. StateFlow holds a value (like LiveData). SharedFlow broadcasts to multiple collectors. stateIn converts cold to hot. buffer/conflate control backpressure.

kotlin
flowOf(1, 2, 3, 4, 5)
    .map { it * 2 }
    .filter { it > 4 }
    .collect { println(it) }  // 6, 8, 10

// Cold flow: emits per collector
// StateFlow: hot, stateful
// SharedFlow: hot, broadcast
val state = MutableStateFlow(0)
state.value = 1

Exception Handling

CoroutineExceptionHandler catches uncaught exceptions in launch. async exceptions propagate to await. SupervisorJob isolates child failures. CancellationException is special: rethrown, not caught by catch. Never swallow CancellationException. Use try/finally or use() for cleanup. Cancellation propagates through suspend calls.

kotlin
val handler = CoroutineExceptionHandler { _, e ->
    Log.e("TAG", "Caught: $e", e)
}
scope.launch(handler) {
    throw RuntimeException("oops")
}
// try/catch in coroutine
try {
    riskyCall()
} catch (e: Exception) {
    // handle
}

Channels

Channels allow coroutine-to-coroutine communication. send suspends when full, receive suspends when empty. capacity: buffered (RENDEZVOUS=0, UNLIMITED, CONFLATED). close() signals completion. produce creates a producer coroutine. Channel is hot: values are consumed once. Prefer Flow for most use cases.

kotlin
val channel = Channel<Int>(capacity = 10)
launch {
    for (i in 1..5) channel.send(i)
    channel.close()
}
launch {
    for (x in channel) println(x)
}
// Produce pattern
fun numbers() = produce {
    for (i in 1..10) send(i)
}

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.