Kotlin essentials for Android

Null safety in practice, data classes and sealed hierarchies, extension and scope functions, and the coroutine basics Android code depends on.

Null safety and data classes

data class Book(
    val id: Long,
    val title: String,
    val subtitle: String? = null,
    val pages: Int = 0,
)

fun preview(book: Book): String {
    val sub = book.subtitle?.takeIf { it.isNotBlank() } ?: "(no subtitle)"
    val length = book.subtitle?.length ?: 0          // safe call plus elvis
    require(book.pages >= 0) { "pages must not be negative" }
    return book.title + " - " + sub + " (" + length + " chars of subtitle)"
}

// the platform boundary is where nulls come from
val name: String = intent.getStringExtra("name") ?: return
  • A nullable type is a compile-time guarantee. Java APIs return platform types, which Kotlin treats as possibly null - so treat every value from an SDK call as nullable until proven otherwise.
  • !! is a crash waiting to happen; use ?: with a default, requireNotNull with a message, or restructure so the null case is impossible.
  • data class gives equals, hashCode, toString, copy and destructuring. It is the right type for state and DTOs, not for a class with behaviour and identity.
  • copy() is the basis of immutable state updates in Compose: change one field, get a new instance, and comparison stays cheap and correct.
⚠️
Never model UI or network state as a nullable field plus a boolean flag. data class UiState(val book: Book?, val loading: Boolean, val error: String?) admits impossible combinations. A sealed hierarchy makes the invalid states unrepresentable.

Sealed hierarchies and scope functions

sealed interface BookUiState {
    data object Loading : BookUiState
    data class Ready(val books: List<Book>) : BookUiState
    data class Failed(val message: String) : BookUiState
}

fun render(state: BookUiState) = when (state) {
    BookUiState.Loading -> "Loading"
    is BookUiState.Ready -> "count=" + state.books.size
    is BookUiState.Failed -> "error: " + state.message
}   // exhaustive: no else branch needed, and adding a state breaks the build

// scope functions
val title = Book(1, "A").let { it.title.uppercase() }
val list = mutableListOf<Int>().apply { add(1); add(2) }
val size = list.run { size * 2 }
val pages = Book(1, "A", pages = 300).also { log("created " + it.id) }.pages

// extension functions: add behaviour without inheritance
fun String.initials(): String =
    split(" ").filter { it.isNotBlank() }.take(2).map { it.first() }.joinToString("")

fun List<Book>.published(): List<Book> = filter { it.pages > 0 }
FunctionReceiver in blockReturnsTypical use
letitThe block resultNull checks, transformations
runthisThe block resultConfigure and compute in one step
applythisThe receiverObject configuration
alsoitThe receiverSide effects in a chain, logging
withthisThe block resultGrouping calls on one object

apply and also return the receiver, so they fit mid-chain; let and run return the block result. Reaching for the wrong one produces code that compiles and reads confusingly, which is the only real cost.

Coroutines you will actually write

suspend fun loadBooks(): List<Book> = withContext(Dispatchers.IO) {
    api.fetchBooks()                        // moves the blocking call off the main thread
}

class BookViewModel(private val repo: BookRepository) : ViewModel() {

    private val _state = MutableStateFlow<BookUiState>(BookUiState.Loading)
    val state: StateFlow<BookUiState> = _state.asStateFlow()

    fun load() {
        viewModelScope.launch {
            _state.value = BookUiState.Loading
            _state.value = runCatching { repo.books() }
                .fold(
                    onSuccess = { BookUiState.Ready(it) },
                    onFailure = { BookUiState.Failed(it.message ?: "Unknown error") },
                )
        }
    }
}

// structured concurrency: a failed child cancels its siblings
suspend fun dashboard(): Dashboard = coroutineScope {
    val profile = async { repo.profile() }
    val orders  = async { repo.orders() }
    Dashboard(profile.await(), orders.await())
}
  • viewModelScope is cancelled automatically when the ViewModel clears, which is why it exists: an unstructured GlobalScope launch leaks and survives the screen.
  • Dispatchers.IO for blocking I/O, Dispatchers.Default for CPU work, Dispatchers.Main for UI. Never block the main thread - it is the source of ANRs.
  • runCatching catches CancellationException too, which breaks structured concurrency. Re-throw it, or catch specific exception types.
  • A StateFlow always has a current value and conflates rapid updates, which is exactly right for UI state. A SharedFlow is for events you must not lose.

FAQ

Do I need to learn coroutines before Compose?
You need the basics: suspend, dispatchers and viewModelScope. Compose depends on state and recomposition, and coroutines are how that state is filled asynchronously, so the two are learned together in practice.
val, var or lateinit?
Prefer val; use var only where mutation is the point. Avoid lateinit for anything that could be read before assignment - a nullable with an explicit check, or a constructor parameter, is safer than a crash at runtime.

Android projects and activities Architecture: ViewModel, lifecycle and saved state

Last refreshed 2026-09-18.