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


Part one covered the everyday class — constructors, properties, init, and inheritance. This is where Kotlin gets interesting. By adding a single keyword to a class, you ask the compiler to bake in a whole set of behaviors for you. Each of these “flavors” exists to make a common pattern safe, concise, and hard to get wrong.

Here’s the tour.

data class

A data class is built for holding values. Add the keyword and the compiler generates equals(), hashCode(), toString(), copy(), and component functions for you:

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

That tiny declaration buys a lot:

val a = User(1, "Ada")

println(a)                       // User(id=1, name=Ada)  — readable toString
println(a == User(1, "Ada"))     // true — structural equality, not reference

val b = a.copy(name = "Grace")   // copy with one field changed
val (id, name) = a               // destructuring into locals

The catch: a data class needs a primary constructor with at least one val/var parameter, and the generated methods consider only those constructor properties. It’s Kotlin’s answer to the same need Java records address — and it predates them by years.

enum class

An enum class represents a fixed, known set of values:

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

Enums can also carry data and behavior, and they pair beautifully with exhaustive when. They’re a deep enough topic that I gave them their own post — worth a read if you’ve only ever used enums as plain constants.

sealed class / sealed interface

A sealed type defines a closed hierarchy — a fixed set of subtypes, all known to the compiler. 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 possible subtype, a when over a sealed type can be exhaustive — no else branch needed, and adding a new subtype turns every unhandled when into a compile error that points you 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. I went deeper on the exhaustiveness angle in the post on when. Sealed types are perfect for modeling results, states, and events — anywhere a class hierarchy is genuinely finite.

object: the built-in singleton

Sometimes you want exactly one instance, ever. An object declaration gives you a singleton with no boilerplate and no thread-safety worries:

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

You use it by name — there’s nothing to construct:

println(AppConfig.version)
AppConfig.reload()

The instance is created lazily on first access and is thread-safe by construction. Objects can implement interfaces and hold state, which makes them handy for things like registries, factories, or a single shared coordinator.

companion object: class-level members

Kotlin has no static. When you want members that belong to the class rather than an instance — factory methods, constants — you put them in a companion object:

class User private constructor(val name: String) {
    companion object {
        fun create(name: String): User = User(name.trim())
    }
}

val u = User.create("  Ada  ")   // called on the class, like a static

Here the primary constructor is private, so the only way to make a User is through the factory — a clean way to validate or normalize input. A companion object can also have a name and implement interfaces, which plain static members never could.

Nested vs inner classes

You can declare a class inside another. By default it’s nested — it does not hold a reference to the outer instance (like a static nested class in Java):

class Outer {
    private val secret = 42

    class Nested {
        fun work(): Int = 1   // cannot see `secret`
    }
}

val n = Outer.Nested()

Add the inner keyword and it becomes an inner class that carries a reference to its enclosing instance and can access its members:

class Outer {
    private val secret = 42

    inner class Inner {
        fun reveal(): Int = secret   // can see `secret`
    }
}

val i = Outer().Inner()   // needs an Outer instance

The default being nested (no outer reference) is the safer choice — it avoids accidental memory leaks and hidden coupling. Reach for inner only when you genuinely need the outer instance.

value class: a type-safe wrapper with no overhead

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

@JvmInline
value class Email(val raw: String)

fun sendWelcome(to: Email) { /* ... */ }

sendWelcome(Email("ada@example.com"))
// sendWelcome("ada@example.com")  // compile error — a raw String won't do

You get a compiler-enforced distinction between an Email and any old String, without paying for a wrapper object in the common case. It’s a cheap way to stamp out a whole category of “passed the arguments in the wrong order” bugs.

Final thoughts

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

  • data → structural equality, copy, destructuring
  • enum → a fixed set of values
  • sealed → a closed hierarchy with exhaustive when
  • object → a singleton
  • companion object → class-level members
  • inner → access to the enclosing instance
  • value → a zero-overhead typed wrapper

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 to one of these the moment the shape of your problem matches.