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:
| Generated | What 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 functions | component1(), 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
}
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.
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.