Android development / Basic: Android and Compose foundations / Lesson 2
Kotlin values, nullability, and domain models
Model reading items with explicit invariants so UI code can make safe decisions without guessing what missing data means.
On this page
Lesson goals
- Use immutable values, data classes, and nullable types deliberately.
- Transform collections while preserving identity and validation rules.
Choose values that express a contract
Kotlin distinguishes val, which cannot be reassigned, from var, which can. A val holding a mutable list does not make that list immutable; the reference and the object have different responsibilities. Prefer immutable-looking domain values and replace them through explicit transformations. Define units and valid ranges near the model rather than scattering checks through buttons. For a reading item, a stable identifier, nonblank title, and nonnegative page count are clearer than a map of arbitrary strings whose keys callers must remember.
Treat missing input as a modeled case
A String? may be null, so callers must decide how absence affects behavior. Safe calls propagate absence; the Elvis operator supplies a fallback; an early return rejects invalid input. These choices carry different product meanings. A missing title should not silently become a successful record named Unknown unless that is a documented rule. Parse external strings with toIntOrNull and validate the resulting number. The non-null assertion operator removes a compiler check but does not establish that a value actually exists at runtime.
data class ReadingItem(val id: String, val title: String, val pages: Int)
fun parseItem(id: String, title: String?, pages: String): ReadingItem? {
val cleanTitle = title?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val count = pages.toIntOrNull()?.takeIf { it >= 0 } ?: return null
return ReadingItem(id, cleanTitle, count)
}
This pure Kotlin excerpt needs no Android APIs. A null result means validation failed; a real form should map the failing field to a specific error instead of reporting every failure identically. The identifier is supplied by a trusted caller here.
Separate identity from display position
A data class generates useful equality and copy operations from constructor properties. Equality compares values, whereas a database identifier establishes continuity across updates. A list index does neither reliably because filtering and sorting move rows. Update the item matching its stable id, produce a new list, and let the UI render that result. copy is shallow: if a property contains a mutable collection, both copies can still refer to the same collection. Prefer immutable elements and avoid exposing mutable internals through a repository interface.
data class ShelfItem(val id: String, val title: String, val finished: Boolean)
fun finish(items: List<ShelfItem>, id: String): List<ShelfItem> =
items.map { item ->
if (item.id == id) item.copy(finished = true) else item
}
The transformation preserves ordering and every existing identifier. If no item matches, the values remain unchanged. Callers must publish the returned list; discarding it does not update existing state. Imports and an application entry point are intentionally omitted.
Make outcomes explicit
Use a sealed interface when an operation has several meaningful outcomes, such as Loading, Ready, and Failed. A when expression can then require handling every known case, reducing combinations of unrelated booleans. Exceptions are appropriate for exceptional failures, while expected validation results often deserve ordinary values. Keep functions small enough to describe in one sentence and avoid hiding mutation inside collection transformations. Lambda syntax is concise, but naming intermediate results can make validation, cancellation, and error handling easier to review than a long chain of clever operators.
Apply Kotlin rules at Android boundaries
Intent extras, JSON responses, database rows, and saved state are all boundaries where values may be missing, stale, or malformed. Validate at entry and convert into a domain type once. Java platform types deserve special care because their nullability may not be fully expressed to Kotlin. Do not use lateinit merely to avoid deciding ownership: accessing it before initialization raises an exception. Constructor injection or a nullable property with a clear lifecycle often expresses the relationship more honestly and makes a local unit test straightforward.
Common errors and fixes
NullPointerException after using !!
- Why it happens
- An external value was asserted present without validation.
- How to fix it
- Handle absence with an explicit validation result or early return.
- Prevent it next time
- Keep nullable types at boundaries and test missing fields.
A supposedly immutable value changes elsewhere
- Why it happens
- A val references a shared mutable collection.
- How to fix it
- Stop exposing mutation and return copied immutable elements.
- Prevent it next time
- Review object mutability separately from variable reassignment.
The wrong row changes after sorting
- Why it happens
- A display index was used as persistent identity.
- How to fix it
- Pass the stable item id to the update operation.
- Prevent it next time
- Test updates after filtering, insertion, and reordering.
Check your understanding
1. Trace parseItem with title containing spaces and pages equal to -3, then with a valid title and pages equal to 12.
Show worked solution
The first call returns null because trimming removes the title; even with a title, the negative count is rejected. The second call returns a ReadingItem with the cleaned title and twelve pages. Neither branch throws for an ordinary malformed numeric string because parsing uses toIntOrNull.
2. A screen has isLoading, hasError, and hasData booleans. Explain an impossible combination and redesign the contract.
Show worked solution
All three can accidentally become true, leaving rendering ambiguous. Replace them with a sealed UI state when the states are mutually exclusive. If stale content may coexist with a refresh error, model that deliberately as content plus refresh status instead of pretending every situation is exclusive.
Key takeaways
- Nullability describes a contract, not an inconvenience to suppress.
- Stable identity survives transformations and screen reordering.
- Explicit result types prevent contradictory UI states.