All topics
Mobile · Learning hub

Kotlin notes for developers

Master Kotlin with a curated set of 2 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Mobile notes
Kotlin

Kotlin Fundamentals

Kotlin Fundamentals Null safety, immutability, data classes, extension functions, and scope functions — the parts of Kotlin that come up in every interview and

Kotlin Fundamentals

Null safety, immutability, data classes, extension functions, and scope functions — the parts of Kotlin that come up in every interview and review.

val vs var, Type Inference

val name = "Alice"     // read-only, like `const`
var count = 0           // mutable, like `let`

// val ≠ immutable object — it's a final reference
val list = mutableListOf(1, 2)
list.add(3)             // OK — list is mutable, ref is not
// list = mutableListOf() // ERROR — val can't be reassigned

// Use `val` by default. Reach for `var` only when you must mutate.

// Type inference works for almost anything; explicit when ambiguous
val id: Long = 1        // forces Long, not Int
val items: List<String> = emptyList()  // type can't be inferred from empty

Null Safety

// Non-nullable by default — won't compile if you assign null
var name: String = "Bob"
// name = null  // ERROR

// Opt into nullable with `?`
var nickname: String? = null

// ?. — safe call, returns null instead of NPE
val length: Int? = nickname?.length

// ?: — Elvis, fallback when left side is null
val len: Int = nickname?.length ?: 0

// !! — assert non-null, throws NPE if null (avoid; use only at boundaries)
val forced: Int = nickname!!.length

// let — run block only when non-null
nickname?.let { println("Hi, \$it") }

// Smart cast — compiler narrows the type after a null check
fun greet(user: String?) {
    if (user != null) {
        println(user.uppercase())  // user is String, not String? here
    }
}

Data Classes

// Auto-generates equals(), hashCode(), toString(), copy(), componentN()
data class User(val id: Long, val name: String, val email: String? = null)

val u1 = User(1, "Alice")
val u2 = u1.copy(name = "Alicia")   // copy with one field changed

u1 == User(1, "Alice")              // true — structural equality
u1 === User(1, "Alice")             // false — different references

// Destructuring via componentN()
val (id, name, email) = u1

// Use cases: DTOs, API responses, value types, sealed-class variants
// Don't use for entities with identity that survives field changes

// Limitations: data classes can't be `open` or `abstract`; primary
// constructor must declare at least one parameter; only fields in the
// primary constructor participate in equals/hashCode/toString.

Extension Functions

// Add methods to classes you don't own — without inheritance
fun String.isValidEmail(): Boolean =
    this.matches(Regex("^[\\w.+-]+@[\\w.-]+\\.[a-z]{2,}\$"))

"alice@dev.com".isValidEmail()   // true
"nope".isValidEmail()            // false

// Extensions are resolved STATICALLY at compile time — not virtual.
// They can't access private members. They're sugar for static functions:
// `String.isValidEmail(this)` is what actually runs.

// Nullable receivers — works on nullable values too
fun String?.orEmpty(): String = this ?: ""
val s: String? = null
s.orEmpty()  // ""  — no NPE

// Common stdlib examples worth memorising:
//   listOf(...).filter { ... }.map { ... }   — collection chains
//   "hello".uppercase().reversed()           — String extensions
//   1.until(10).step(2).toList()             — Range builders

Scope Functions: let / run / with / apply / also

// All five run a block on an object. They differ in (a) how you refer to
// the object (`it` or `this`) and (b) what the block returns.
//
//   Function   Receiver   Returns          Use for
//   ─────────  ─────────  ───────────────  ───────────────────────────────
//   let        it         block result     null check, mapping
//   run        this       block result     init + compute on one object
//   with       this       block result     calling many methods on object
//   apply      this       the object       object configuration / builders
//   also       it         the object       side effects (log, debug)

// let — null-safe transform
val len: Int? = nickname?.let { it.length }

// apply — configure and return the same object
val intent = Intent().apply {
    action = Intent.ACTION_VIEW
    putExtra("id", 42)
}

// also — side effect, no transform
val users = fetchUsers().also { println("loaded \${it.size}") }

// run — group setup and return a result
val label = User(1, "Alice").run { "\$id: \$name" }

// with — non-extension version of run, for libraries / external objects
val json = with(StringBuilder()) {
    append('{'); append("\"a\":1"); append('}'); toString()
}

Sealed Classes & When

// Sealed = closed hierarchy known at compile time. Compiler can verify
// `when` is exhaustive — no `else` branch needed.
sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: Throwable) : Result<Nothing>()
    data object Loading : Result<Nothing>()
}

fun render(r: Result<String>): String = when (r) {
    is Result.Success -> "OK: \${r.value}"   // smart-cast to Success<String>
    is Result.Failure -> "Err: \${r.error.message}"
    Result.Loading    -> "..."
    // no `else` — compiler knows the hierarchy is exhaustive
}

// Compare to enums: sealed can carry per-case data; enum cases are singletons.
// Use enum when cases have no state; sealed when they do.
Kotlin

Coroutines, Flow & Structured Concurrency

Coroutines, Flow & Structured Concurrency Lightweight concurrency: suspend functions, scopes, dispatchers, Flow, cancellation, and the common pitfalls. suspend

Coroutines, Flow & Structured Concurrency

Lightweight concurrency: suspend functions, scopes, dispatchers, Flow, cancellation, and the common pitfalls.

suspend Functions

// `suspend` marks a function that may pause without blocking a thread.
// Can only be called from another suspend function or a coroutine builder.
suspend fun fetchUser(id: Long): User {
    delay(100)              // non-blocking — suspends, frees the thread
    return api.getUser(id)
}

// A suspending call compiles to a continuation-passing form. There's no
// magic — the function literally takes a hidden Continuation parameter
// and may return a marker (COROUTINE_SUSPENDED) instead of a value.

// Sequential by default — these run one after another
suspend fun loadProfile(): Profile {
    val user = fetchUser(1)
    val posts = fetchPosts(user.id)
    return Profile(user, posts)
}

// Concurrent — launch in parallel with `async`
suspend fun loadProfileParallel(): Profile = coroutineScope {
    val user = async { fetchUser(1) }
    val posts = async { fetchPosts(1) }
    Profile(user.await(), posts.await())
}

Builders: launch / async / runBlocking

// launch — fire-and-forget. Returns Job. Use when you don't need a result.
val job: Job = scope.launch {
    saveLog("hi")
}

// async — returns Deferred<T>. Use when you need the result.
val result: Deferred<Int> = scope.async { compute() }
val value: Int = result.await()

// runBlocking — blocks the current thread until the block finishes.
// Use in `main()` or tests; never in production app code.
fun main() = runBlocking {
    launch { delay(100); println("a") }
    launch { delay(50);  println("b") }
}   // prints: b, a

// withContext — switch dispatcher within a suspend function
suspend fun readFile(path: String): String = withContext(Dispatchers.IO) {
    File(path).readText()
}

Dispatchers

// Dispatchers.Main      — UI thread on Android/Compose. Updates UI state.
// Dispatchers.Default   — CPU-bound. Sorting, parsing, compression.
// Dispatchers.IO        — blocking I/O. File, network, JDBC.
// Dispatchers.Unconfined — runs on whatever thread resumed it. Tests only.

// Rule of thumb:
//   - call suspend networking APIs on whatever dispatcher (they're already
//     non-blocking under the hood — Ktor, Retrofit suspend funcs)
//   - wrap blocking JDBC / file reads with `withContext(Dispatchers.IO)`
//   - run heavy computation with `withContext(Dispatchers.Default)`
//   - never block the main thread

suspend fun summarise(text: String): String = withContext(Dispatchers.Default) {
    text.split(" ").groupingBy { it }.eachCount().toString()
}

Structured Concurrency & Cancellation

// Every coroutine belongs to a CoroutineScope. The scope owns its
// children — cancelling the scope cancels every coroutine launched from it.
class UserViewModel : ViewModel() {
    init {
        viewModelScope.launch { observeUser() }
        // viewModelScope is cancelled when the ViewModel is cleared.
    }
}

// If one child fails, by default the parent cancels and so do its siblings.
// Use `SupervisorJob` / `supervisorScope` when failures should be isolated.
supervisorScope {
    launch { riskyA() }   // failure here won't cancel riskyB
    launch { riskyB() }
}

// Cancellation is cooperative. Suspending functions in stdlib check for
// cancellation; tight loops won't unless you call yield() or ensureActive().
suspend fun crunch() {
    repeat(1_000_000) { i ->
        if (i % 1000 == 0) yield()  // cancellation checkpoint
        process(i)
    }
}

// Timeouts
val r: String? = withTimeoutOrNull(500) { fetchSlow() }

Flow — Cold Async Streams

// Flow is the async/streaming counterpart of Sequence. Cold: produces
// values only when something collects it.
fun tickerFlow(period: Long): Flow<Int> = flow {
    var i = 0
    while (true) {
        emit(i++)
        delay(period)
    }
}

scope.launch {
    tickerFlow(1000)
        .map { it * 2 }
        .filter { it < 10 }
        .collect { println(it) }   // 0, 2, 4, 6, 8
}

// StateFlow — hot, holds current value. Like LiveData / Subject
val state = MutableStateFlow<Result<User>>(Result.Loading)
state.value = Result.Success(user)        // emit by setting `value`

// SharedFlow — hot, broadcasts to N collectors, configurable replay
val events = MutableSharedFlow<UiEvent>()
events.emit(UiEvent.Saved)                // suspending

// Cancelling the collecting coroutine cancels the upstream flow.
// Operators like `combine`, `flatMapLatest`, `debounce`, `distinctUntilChanged`
// are the bread-and-butter of view-model logic.

Common Pitfalls

  • Calling blocking IO without withContext(Dispatchers.IO) — blocks the dispatcher thread pool.

  • Using GlobalScope — coroutines outlive their owner. Always use a scope tied to lifecycle (viewModelScope, lifecycleScope, custom).

  • Catching CancellationException — swallowing it breaks structured concurrency. Rethrow after cleanup.

  • launch { ... }.await() — Job has no await, only Deferred does. Use async if you need the result.

  • Forgetting that Flow is cold — every collect re-runs the upstream. Use shareIn/stateIn to share.

Keep your Kotlin knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever