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, destructuringenum→ a fixed set of valuessealed→ a closed hierarchy with exhaustivewhenobject→ a singletoncompanion object→ class-level membersinner→ access to the enclosing instancevalue→ 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.