Kotlin is a large language, and an Android app uses a fairly small, fairly predictable part of it every day. This lesson is that part — not a language reference, but the features this app leans on in nearly every file, shown in the files where it leans on them.
If you know Java, most of this will feel like Java with the boilerplate removed and the
NullPointerException designed out. If you know Swift, you will recognise almost
everything; the differences are noted as they come up.
val, var, and data classes
val declares a read-only reference; var a reassignable one. The habit
worth forming on day one is val until proven otherwise. A value that
cannot change is a value nobody has to track.
Most of an app's model is plain data, and Kotlin has a construct for exactly that:
data class Topping(
val id: UuidString,
val name: String,
val price: Double,
val category: ToppingCategory,
val active: Boolean,
)
One line of declaration buys a constructor, read-only properties, equals and
hashCode that compare every field, a readable toString, and
copy. In Java that is fifty lines or a Lombok annotation.
copy is the one that changes how you write code. A cart line is immutable — every
property a val — so changing its quantity means building a new one:
is CartAction.SetQuantity ->
if (action.quantity <= 0) {
// Dropping to zero removes the line — it is what a customer expects from a "−"
// button, and a line with quantity 0 is a state nothing downstream should handle.
state.copy(items = state.items.filterNot { it.lineId == action.lineId })
} else {
state.copy(
items = state.items.map {
if (it.lineId == action.lineId) it.copy(quantity = action.quantity) else it
},
)
}
it.copy(quantity = action.quantity) returns a new line identical to the old one
except for the named field. Nothing that held a reference to the old line sees it change underneath
it, which is what makes the cart's rules trivially safe. (Swift gets the same effect from
struct value semantics; Kotlin gets it from immutability plus copy.)
Named and default arguments
That call also shows a feature you will use constantly: arguments by name. Constructors with many parameters stay readable, and with defaults most of them can be left out:
data class CartItem(
val lineId: String = UUID.randomUUID().toString(),
val productId: UuidString,
val productName: String,
val productType: ProductType,
val imageUrl: String? = null,
val size: SizeName,
val basePrice: Double,
val crustId: UuidString? = null,
val crustName: String? = null,
val crustPriceDelta: Double = 0.0,
val toppings: List<Topping> = emptyList(),
val quantity: Int,
) {
// …
A drink has no crust and no toppings, so building one names the four or five fields that matter and the defaults cover the rest. Default parameters also replace most of Java's overloaded constructors.
Null safety
This is the feature that justifies Kotlin on its own. A type is either nullable or it is not, and
the compiler enforces the difference: String can never hold null;
String? can, and you cannot call a method on it until you have dealt with that.
The tools for dealing with it are short:
val formattedAddress: String?
get() {
if (addressLine1 == null || city == null || state == null || postalCode == null) return null
val street = addressLine2?.let { "$addressLine1, $it" } ?: addressLine1
return "$street\n$city, $state $postalCode"
}
- Smart casts. After the
if (… == null) return nullline, the compiler knows all four properties are non-null and lets you use them as plainStrings. No casting, no assertions. ?.— the safe call.addressLine2?.let { … }runs the block only when there is a value, and isnullotherwise.?:— the "Elvis" operator. Use the left side if it is non-null, otherwise the right.
The standard library is designed around this. Asking a list for its minimum returns
null for an empty list instead of throwing, and the model passes that honesty on:
val cheapestPrice: Double?
get() = sizes.minOfOrNull { it.price }
A product with no sizes is a valid response from the API. Returning 0.0 would show
"From $0.00", which is a lie; returning null forces every caller to decide what to show
instead. The product card shows "Unavailable".
The wrong way: !!
!! converts a nullable value to non-null by throwing if it is null. It is the
"trust me" operator, and in app code it is almost always the wrong way to get rid of a compiler
error:
// ✗ crashes the app on the first product with no sizes
val price = product.cheapestPrice!!
The compiler was pointing at a real case you had not handled. !! silences it by
turning a type error into a crash on a customer's phone. In this whole app it appears once — in a
test, where a crash is the failure report.
Enums, sealed types and when
An enum can carry behaviour, which keeps knowledge about a value next to the value:
enum class OrderStatus {
@SerialName("PENDING_PAYMENT") PENDING_PAYMENT,
@SerialName("PAID") PAID,
@SerialName("PREPARING") PREPARING,
@SerialName("COMPLETED") COMPLETED,
@SerialName("CANCELLED") CANCELLED,
;
/** "pending payment" — what the badge shows. */
val displayName: String
get() = name.replace('_', ' ').lowercase()
/** The confirmation screen keeps polling while this is true. */
val isSettling: Boolean
get() = this == PENDING_PAYMENT
}
The @SerialName annotations map each constant to the exact spelling on the wire —
the JSON says "PENDING_PAYMENT", and that is all the decoder needs.
Enums cover "one of a fixed set of values". When each case carries different data, Kotlin has sealed types. This is how every screen in the app describes what it is showing:
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Loaded<T>(val value: T) : UiState<T>
data object Empty : UiState<Nothing>
data class Failed(val message: String, val isRetryable: Boolean) : UiState<Nothing>
// …
A sealed interface can only be implemented in its own package and module, so the
compiler knows the complete list of cases. That makes when exhaustive:
when (state) {
UiState.Idle, UiState.Loading -> LoadingState("Loading the menu", modifier)
is UiState.Failed -> ErrorState(state.message, state.isRetryable, onRetry, modifier)
UiState.Empty -> EmptyState("🍕", "The menu is empty", modifier, "Check back soon.")
// …
There is no else branch, and that is the point. Add a sixth case to
UiState and every screen that switches on it stops compiling until someone decides what
it should show. The bug where a screen spins forever because nobody wrote the failure branch cannot
be written.
Two kinds of case appear in it. data class Loaded<T>(val value: T) carries data;
data object Loading carries none, so there is exactly one instance of it and comparing
against it is free. (Swift spells this an enum with associated values; Kotlin splits it
into enums for plain constants and sealed types for everything else.)
Extension functions
You can add a function to a type you did not write. The cart needs to turn a line into the shape the server wants, and the cleanest place to say so is on the line itself:
fun CartItem.toLineItemRequest(): LineItemRequest = LineItemRequest(
productId = productId,
size = size,
crustId = crustId,
toppingIds = toppings.map { it.id },
quantity = quantity,
)
Call sites then read cart.items.map { it.toLineItemRequest() }. It is compiled to an
ordinary static function — no inheritance, no wrapper, no access to private members — so it is
purely a readability tool, and a good one. Kotlin's own standard library is mostly extension
functions on Java's collections.
Lambdas and the collection functions
Kotlin functions take functions as parameters, and when the last parameter is a function it can
move outside the parentheses. Together with the collection library, that replaces almost every
for loop you would write in Java:
fun unitPrice(item: CartItem): Double =
Money.rounded(item.basePrice + item.crustPriceDelta + item.toppings.sumOf { it.price })
val fieldErrors: Map<String, String>
get() {
val subErrors = (this as? Api)?.body?.errors ?: return emptyMap()
return subErrors.mapNotNull { sub -> sub.field?.let { it to sub.message } }.toMap()
}
it is the implicit name of a single-parameter lambda. mapNotNull maps
and drops the nulls in one pass, to builds a pair, and toMap turns pairs
into a map. Read left to right, the second example says exactly what it does: "the sub-errors that
have a field, as field → message".
The scope functions
Five small functions — let, apply, also, run,
with — that run a block with an object in scope. Two cover nearly every real use:
apiConfig.stripePublishableKey?.let { PaymentConfiguration.init(this, it) }
?.let is "if this is not null, do something with it". Here: only
configure Stripe if a key was supplied.
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
apply configures an object and returns it — Kotlin's answer to a
builder for APIs that were not written with one. The rule of thumb: let to
transform or use a value, apply to set up one. Chains of three scope
functions in a row are a sign to use a plain local variable instead.
object, companion object, typealias
Kotlin has no static. A singleton is an object, which is the natural home
for stateless helpers:
object Money {
/**
* Rounds to cents, half up.
*
* Binary floating point cannot represent 13.99, so 13.99 + 1.5 + 1.75 lands on
* 17.240000000000002. Rounding at each boundary keeps displayed totals sane.
*
* ⚠️ `Math.round`, not `kotlin.math.round`. They look interchangeable and are not:
* `kotlin.math.round` rounds half to EVEN (banker's rounding), so 0.125 becomes 0.12.
* `Math.round` rounds half up, which is what a customer expects from a price.
*/
fun rounded(value: Double): Double = Math.round(value * 100) / 100.0
// …
Functions that belong to a type rather than an instance live in a
companion object, which is how UiState.resolved(list) and
CartTotals.EMPTY are written. And a typealias names an existing type
without creating a new one:
typealias UuidString = String
Every id the API returns is a UUID string. The alias documents that at every use site, at no cost
— and deliberately without the strictness of java.util.UUID, whose parser would turn
one oddly-formatted id into an order history that fails to load.
Errors are exceptions
Kotlin has no checked exceptions: nothing forces a caller to catch anything. Error handling in this app is therefore a design, not a compiler feature — one sealed class whose cases are the decisions a caller can make:
sealed class ApiError(override val message: String) : Exception(message) {
class Api(val status: Int, message: String, val body: ApiErrorBody?) : ApiError(message)
data object Unauthorized : ApiError("Your session has expired. Please sign in again.")
class Network(message: String) : ApiError(message)
class Decoding(message: String) : ApiError(message)
// …
It extends Exception so it can be thrown from a suspend function and
caught with an ordinary try. The error-handling lesson builds this out; the point here is
the shape — a sealed hierarchy, so a when over it is exhaustive just like
UiState.
Two more things to know before the first suspend function appears. Coroutines report
cancellation by throwing CancellationException, which a careless
catch (e: Exception) swallows. And runCatching { } exists, but wrapping a
coroutine call in it has the same problem. Both come back in the coroutines lesson.
Next
With the language in hand, the UI. The next lesson is Jetpack Compose: what a composable is, how Column, Row and Box lay things out, and why the order of modifiers is not decoration but the layout itself.