One Keyword Does the Work: Kotlin's data, enum, sealed, and object

Beyond the plain class — data classes and why they're final, sealed hierarchies, objects and companions with const and @JvmStatic, nested vs inner, and value classes with the boxing that undoes their savings. Part 2 of two.

Part one covered the everyday class — constructors, properties, init, inheritance. This is where Kotlin gets interesting: by adding a single keyword you ask the compiler to bake in a whole set of behaviors. Each flavor exists to make a common pattern safe, concise, and hard to get wrong. Everything here was run against Kotlin 2.4.10.

data class

A data class is built for holding values. The keyword generates equals(), hashCode(), toString(), copy(), and componentN() functions from the primary-constructor properties:

data class User(val id: Long, val name: String)

val a = User(1, "Ada")
println(a)                       // User(id=1, name=Ada)
a == User(1, "Ada")             // true — structural equality
val b = a.copy(name = "Grace")   // copy with one field changed
val (id, name) = a               // destructuring

Three sharp edges are worth carrying. First, the generated methods consider only the primary-constructor properties — a property declared in the body is invisible to equals, hashCode, and toString. Second, copy() is a shallow copy: it duplicates the top-level object but shares every referenced object, so original.copy().items is the same mutable list. Third, and the one that surprises people: a data class is final and cannot be made open — try to subclass one and the compiler says this type is final, so it cannot be extended. That’s deliberate: an inheritable data class would make equals symmetry impossible to guarantee. Data classes model values, and values don’t have subclasses.

enum class

An enum class is a fixed, known set of values, and in Kotlin it can carry data, behavior, and per-constant overrides — enough that it gets its own chapter:

enum class Direction { NORTH, SOUTH, EAST, WEST }

Its real power is exhaustive when handling, shared with the next flavor.

sealed class / sealed interface

A sealed type defines a closed hierarchy — a fixed set of subtypes, all known to the compiler because they must live in the same module (and, for classes, be declared in the same package). It’s the tool for “this value is exactly one of these shapes”:

sealed interface PaymentResult {
    data class Success(val transactionId: String) : PaymentResult
    data class Failure(val reason: String) : PaymentResult
    data object Pending : PaymentResult
}

Because the compiler knows every subtype, a when over a sealed type is exhaustive — no else needed — and adding a subtype turns every unhandled when into a compile error pointing exactly where to fix it:

fun describe(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Paid (id: ${result.transactionId})"
    is PaymentResult.Failure -> "Failed: ${result.reason}"
    PaymentResult.Pending    -> "Still processing"
}

This is one of Kotlin’s best refactoring aids (the when chapter goes deeper on exhaustiveness). Sealed types are ideal for modeling results, states, and events — anywhere a hierarchy is genuinely finite. Note data object Pending: a data object is a singleton with a sensible generated toString (Pending rather than an identity hash), which is exactly what you want for a stateless variant.

object: the built-in singleton

For exactly one instance, ever, an object declaration gives a singleton with no boilerplate:

object AppConfig {
    val version = "1.0.0"
    fun reload() { /* ... */ }
}
AppConfig.reload()

The instance is created lazily on first access and is thread-safe by construction (the JVM’s class-initialization lock does the work). Objects can implement interfaces and hold state, which makes them useful for registries, factories, or a single shared coordinator.

companion object: class-level members

Kotlin has no static. Members belonging to the class rather than an instance — factories, constants — go in a companion object:

class User private constructor(val name: String) {
    companion object {
        const val MAX_NAME = 50           // compile-time constant
        fun create(name: String): User = User(name.trim())
    }
}
User.create("  Ada  ")   // called on the class, like a static

Two interop details matter the moment Java calls this code. A companion object compiles to a real nested object, so a Java caller reaches a method as User.Companion.create(...) unless you annotate it @JvmStatic, which also emits a genuine static method. And const val (only valid for compile-time primitives and strings, only inside an object/companion or at top level) inlines the value into call sites and becomes a real Java static final — the right choice for public constants. A plain val in a companion is a method call behind a getter; const val is a true constant.

Nested vs inner classes

A class declared inside another is nested by default — it holds no reference to the outer instance, like a static nested class in Java:

class Outer {
    private val secret = 42
    class Nested { fun work() = 1 }       // cannot see `secret`
}
val n = Outer.Nested()

Add inner and it carries a reference to its enclosing instance and can access its members:

class Outer {
    private val secret = 42
    inner class Inner { fun reveal() = secret }   // can see `secret`
}
val i = Outer().Inner()                            // needs an Outer instance

Nested-by-default is the safer choice — no accidental outer reference to leak (the same this-capture hazard the closures chapter flagged). Reach for inner only when you genuinely need the outer instance.

value class: a typed wrapper, and the boxing that undoes it

Domain code is full of Strings and Ints that mean something specific — an email, a user id, a quantity. A value class wraps one value in a distinct type for safety while the compiler inlines it away at runtime, so there’s usually no allocation:

@JvmInline
value class Email(val raw: String)

fun sendWelcome(to: Email) { /* ... */ }
sendWelcome(Email("[email protected]"))
// sendWelcome("[email protected]")   // compile error — a raw String won't do

You get a compiler-enforced distinction between an Email and any String, stamping out a category of “arguments in the wrong order” bugs. The caveat that keeps it honest: the inlining only holds when the value class is used directly. The moment it appears as a nullable (Email?), as a generic type argument (List<Email>), or where a supertype is expected, the compiler boxes it into a real wrapper object — the same erasure pressure that boxes Int into Integer. So a value class is free in the common path and not free at those boundaries; reach for it to make domain types distinct, not as a guaranteed zero-cost abstraction everywhere.

Final thoughts

None of these are features bolted on — they’re classes with a modifier that tells the compiler to generate or enforce something:

  • data → structural equality, copy, destructuring (and it’s final)
  • enum → a fixed set of values
  • sealed → a closed hierarchy with exhaustive when
  • object / companion object → singletons and class-level members
  • inner → access to the enclosing instance
  • value → a typed wrapper, free until it’s boxed

The skill isn’t memorizing them — it’s recognizing which pattern you’re reaching for and letting the right keyword do the work. Start with a plain class from part one, and promote it the moment your problem’s shape matches.

Next: enums up close — the one flavor deep enough to repay a full chapter, carrying data, behavior, and exhaustive when handling.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments