Data Classes & Sealed Classes

Two of Kotlin's class declarations do an outsized amount of work for how little you type: data class, which turns a handful of properties into a complete, well-behaved model object, and sealed class, which lets you tell the compiler "this is the complete, fixed list of things this can be — nothing else." Both show up constantly in TrailLog's codebase, and both exist to eliminate a specific, common source of bugs: hand-written boilerplate that's easy to get subtly wrong, and "forgot to handle one case" logic errors.

data class: A Model Object, One Line

Here is TrailLog's actual Trail data class, describing one logged hike:

data class Trail(
    val id: Long,
    val name: String,
    val notes: String,
    val photoUri: String?,
    val latitude: Double,
    val longitude: Double,
    val distanceKm: Double,
    val date: Long
)

Adding the data keyword before class tells the compiler to generate several things automatically, purely from the properties declared in the constructor:

GeneratedWhat it does
equals()compares two Trail instances field-by-field, so trail1 == trail2 checks actual content equality, not just reference identity
hashCode()a matching hash code, so Trail objects behave correctly as keys in a Map or entries in a Set
toString()a readable string like Trail(id=1, name=Bear Ridge Loop, ...) — genuinely useful in logs and debugging
copy()creates a new instance with some properties changed and the rest kept identical
component functionscomponent1(), component2(), etc. — what makes destructuring (below) possible

Writing all of that by hand for an eight-property class in Java would run to well over a hundred lines. Here, it's the eight-line constructor above, plus one keyword.

Immutable Copies With copy()

Every property on Trail is declared with val, meaning once a Trail instance is created, none of its fields can be reassigned — it's immutable. That's a deliberate, genuinely good habit: instead of mutating an existing object (and risking some other part of the app still holding a reference to the "old" state), you create a new instance with just the fields you want changed:

val original = Trail(
    id = 1, name = "Bear Ridge Loop", notes = "First attempt",
    photoUri = null, latitude = 37.4275, longitude = -122.1697,
    distanceKm = 4.8, date = 1755292800000
)

// Re-measured the same hike with GPS — create a corrected copy
val corrected = original.copy(distanceKm = 5.2)

// original.distanceKm is still 4.8 — completely untouched
// corrected.distanceKm is 5.2 — a brand new Trail instance

This ties directly back to preferring val over var: when objects can't be mutated out from under you, an entire class of bugs disappears — you never have to wonder whether some other function quietly changed a Trail you're still holding a reference to. If you need a "changed" version, copy() gives you one explicitly, at the exact point you ask for it.

Destructuring a Data Class

The component functions a data class generates also enable destructuring — pulling several properties out into separate variables in one line:

val (name, notes) = trail
// name  -> trail.component1(), i.e. trail.name
// notes -> trail.component2(), i.e. trail.notes

Destructuring reads properties in declaration order, so it's most useful for small, well-known structures (like a coordinate pair, or the first couple of fields you actually need) rather than pulling every field of a large data class this way.

sealed class: A Fixed, Known Set of Possibilities

A sealed class declares a type where every possible subclass is known and restricted to the same file (or same package, in newer Kotlin versions). This matters because it lets the compiler do something a plain open class hierarchy can't: verify, at compile time, that you've handled every possible case.

Consider loading TrailLog's trail list — the UI needs to represent three distinct states: still loading, loaded successfully with data, or failed with an error:

sealed class TrailLoadResult {
    object Loading : TrailLoadResult()
    data class Success(val trails: List<Trail>) : TrailLoadResult()
    data class Error(val message: String) : TrailLoadResult()
}

Using it with a when expression that returns a value forces exhaustiveness:

fun render(result: TrailLoadResult) {
    val message = when (result) {
        is TrailLoadResult.Loading -> "Loading your trails..."
        is TrailLoadResult.Success -> "Found ${result.trails.size} trails"
        is TrailLoadResult.Error -> "Couldn't load trails: ${result.message}"
    }
    statusText.text = message
}
The genuine safety net: because TrailLoadResult is sealed, the compiler knows Loading, Success, and Error are the only possible subclasses. If you delete the Error branch above, the code simply won't compile — Kotlin reports "when expression must be exhaustive, add a branch for Error." With a plain (non-sealed) class hierarchy, the compiler has no way to know the full list of subtypes, so a forgotten branch would just silently do nothing at runtime instead of failing the build. That's the real value: a whole class of "I forgot to handle a case" bugs turns into a compile error you can't ship past.
Where this pattern comes back: This exact shape — a sealed class with Loading/Success/Error-style subclasses, handled with an exhaustive when — reappears later in Error Handling with Sealed Results, where we use it to represent the outcome of a real network call.