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,requireNotNullwith a message, or restructure so the null case is impossible.data classgives 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 }| Function | Receiver in block | Returns | Typical use |
|---|---|---|---|
let | it | The block result | Null checks, transformations |
run | this | The block result | Configure and compute in one step |
apply | this | The receiver | Object configuration |
also | it | The receiver | Side effects in a chain, logging |
with | this | The block result | Grouping 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())
}viewModelScopeis cancelled automatically when the ViewModel clears, which is why it exists: an unstructuredGlobalScopelaunch leaks and survives the screen.Dispatchers.IOfor blocking I/O,Dispatchers.Defaultfor CPU work,Dispatchers.Mainfor UI. Never block the main thread - it is the source of ANRs.runCatchingcatchesCancellationExceptiontoo, which breaks structured concurrency. Re-throw it, or catch specific exception types.- A
StateFlowalways has a current value and conflates rapid updates, which is exactly right for UI state. ASharedFlowis 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.Related
Android projects and activities Architecture: ViewModel, lifecycle and saved state
Last refreshed 2026-09-18.