Android – The Kotlin You Need First

August 8, 20269 min readUpdated 10/11/2026

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 null line, the compiler knows all four properties are non-null and lets you use them as plain Strings. No casting, no assertions.
  • ?. — the safe call. addressLine2?.let { … } runs the block only when there is a value, and is null otherwise.
  • ?: — 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.