Kotlin Classes — Data, Sealed, and Objects
How Kotlin models the world: concise constructors and real properties, data classes that generate their own equals and copy, sealed hierarchies for exhaustive state, the object keyword that replaces static, and delegation as a first-class tool.
Classes and the primary constructor
A Java class that just holds a name and an age needs a field declaration, a constructor that assigns each field, and usually a pair of accessors — a dozen lines of mechanical code. Kotlin collapses all of that into the class header itself, using the primary constructor.
class Person(val name: String, var age: Int)
That single line declares a class, a constructor taking two arguments, and two properties — name (read-only, because val) and age (mutable, because var). There is no body because none is needed. When you do need setup logic that runs at construction, an init block handles it, and it can see the constructor parameters:
class Person(val name: String, var age: Int) {
init {
require(age >= 0) { "age cannot be negative" }
}
}
A class can also declare secondary constructors with the constructor keyword for alternative ways to build it, but in practice Kotlin's default and named arguments usually make them unnecessary — instead of three overloaded constructors, you write one primary constructor with default values. This is the same conciseness pillar from the flagship episode, applied to object construction: say what the class is, and let the compiler write the ceremony.
Properties, not fields
A subtle but important difference from Java: in Kotlin you declare properties, never raw fields. val name: String is not a field — it is a property with a generated getter (and, for var, a setter). You always interact with a property through its accessors, even when you write what looks like direct access.
This means you can give a property custom accessor logic without changing how callers use it — no need to refactor a public field into a getter later, the Java tax that gave the world endless boilerplate getters "just in case."
class Rectangle(val width: Int, val height: Int) {
// A computed property — no stored value, just a getter
val area: Int
get() = width * height
var label: String = ""
set(value) {
field = value.trim() // 'field' is the backing field
}
}
val r = Rectangle(3, 4)
println(r.area) // 12 — looks like a field, runs the getter
The special identifier field inside an accessor refers to the backing field — the actual storage behind the property. Kotlin only generates a backing field when a property needs one (when an accessor actually references field); a purely computed property like area stores nothing at all. This "everything is a property" model is what lets Kotlin classes stay so small while remaining flexible.
Data classes
Many classes exist only to hold data — a request, a response, a row, a coordinate. For these, writing equals(), hashCode(), and toString() by hand is tedious and a frequent source of bugs (forget one field in equals and you get baffling behaviour). The data modifier tells the compiler to generate them all from the primary-constructor properties.
data class Point(val x: Int, val y: Int)
val a = Point(1, 2)
val b = Point(1, 2)
println(a == b) // true — value equality, not reference
println(a) // Point(x=1, y=2)
val c = a.copy(y = 99) // Point(x=1, y=99) — clone with one change
val (x, y) = a // destructuring via componentN()
Two generated members deserve a note. copy() is the workhorse of immutable data: rather than mutating a Point, you make a near-identical copy with a few fields changed — the foundation of immutable state in modern Kotlin UIs. And componentN() functions enable destructuring, letting you unpack an object into several variables at once.
A few rules keep data classes honest: they must have at least one primary-constructor parameter, and the generated
equals/hashCode/toString consider only the properties declared in the primary constructor — anything declared in the class body is ignored by them. They also cannot be abstract or sealed. They are a tool for plain data, and they are superb at it.
object and companion object — Kotlin has no static
Kotlin removed a keyword every Java programmer knows: there is no static. The reasoning is that static members sit awkwardly outside the object model — they are not part of any instance, cannot implement interfaces cleanly, and complicate inheritance. Kotlin replaces static with something more principled: the object keyword.
An object declaration defines a class and its single instance in one go — a singleton, baked into the language so you never write the tricky thread-safe singleton boilerplate again.
object AppConfig {
val version = "1.0.0"
fun describe() = "App v$version"
}
AppConfig.describe() // access the one instance directly
When you want members tied to a class rather than to instances — the role static played — you nest a companion object inside the class. Members of the companion are accessed through the class name, just like static members, but the companion is a real object: it can implement interfaces, hold state, and be passed around.
class User private constructor(val name: String) {
companion object {
// a factory function — the idiomatic replacement for static methods
fun create(name: String): User = User(name.trim())
}
}
val u = User.create(" Ankit ") // looks static, but it's a companion call
A common pattern combines the two ideas: a private constructor plus a companion factory function, giving you full control over how instances are made. Objects also appear as anonymous objects (object : Listener { ... }), Kotlin's replacement for Java's anonymous inner classes.
Sealed classes: closed hierarchies
Often a value is one of a fixed, known set of shapes. A network result is Loading, Success, or Error. A UI event is one of a handful of kinds. An enum can model a fixed set of constants, but what if each case needs to carry different data — a success holds a payload, an error holds a message? That is what a sealed class (or sealed interface) is for.
"Sealed" means the set of direct subclasses is known and closed at compile time — they must all be declared in the same module. The compiler therefore knows every possible case, which it uses to make when exhaustive without an else.
sealed interface Result<out T> {
data object Loading : Result<Nothing>
data class Success<T>(val data: T) : Result<T>
data class Error(val message: String) : Result<Nothing>
}
fun render(result: Result<User>) = when (result) {
Result.Loading -> showSpinner()
is Result.Success -> showUser(result.data) // smart-cast
is Result.Error -> showError(result.message)
// no else needed — all cases covered
}
The real payoff is maintenance. If you later add a fourth case — say Empty — every when that handled Result without an else immediately fails to compile, pointing you to exactly the places that must handle the new state. This turns a whole class of "forgot to handle that case" runtime bugs into compile errors. (Notice Nothing from the Type System episode doing real work here, and the out T variance making Loading usable as any Result.)
Delegation: enums, interfaces, and by
Two more pieces complete Kotlin's object model. First, enum classes — like Java's, but able to carry properties and methods on each constant:
enum class Planet(val gravity: Double) {
EARTH(9.81), MARS(3.71), MOON(1.62);
fun weight(mass: Double) = mass * gravity
}
Second, and more distinctively, Kotlin makes delegation a first-class language feature through the by keyword — a direct nod to the design principle "favour composition over inheritance." Class delegation lets one class implement an interface by forwarding the work to another object, with zero boilerplate:
interface Logger { fun log(msg: String) }
class ConsoleLogger : Logger {
override fun log(msg: String) = println(msg)
}
// Service IS-A Logger, but delegates all Logger methods to 'impl'
class Service(impl: Logger) : Logger by impl
Property delegation applies the same idea to a single property: the property's get/set are handled by a delegate object. The standard library ships several, and they are everywhere in real code — by lazy { } computes a value once on first access, by Delegates.observable { } fires a callback on every change, and by map backs a property with a map entry:
val config: Config by lazy { loadConfig() } // computed once, on first use
var name: String by Delegates.observable("") { _, old, new ->
println("name: $old -> $new")
}
Finally, a design decision worth knowing: in Kotlin, classes and members are final by default. To allow inheritance or overriding you must explicitly mark them open. This directly follows Joshua Bloch's advice in Effective Java — "design and document for inheritance, or else prohibit it" — making the safe choice the default and forcing you to opt in to the fragile one. It is the same philosophy as null-safety and immutability: Kotlin nudges you toward the robust option and asks you to be deliberate about the risky one.
AI prompts — modelling with classes
Use these prompts with an AI assistant to go deeper on the ideas in this episode.
Prompt 1 — data class or regular class:
For each of these types, tell me whether it should be a data class,
a regular class, an enum, or a sealed type, and why:
[list your types]
Flag any where value equality or copy() would be wrong.
Prompt 2 — model state with a sealed type:
I have a screen with these states: [loading, content, empty, error...].
Model them as a sealed interface with the right data on each case,
and show an exhaustive when that renders each one.
Prompt 3 — replace static and singletons:
This Java/Kotlin code uses static members or a hand-written singleton:
[paste code]
Rewrite it idiomatically using object and companion object, including
a factory function if appropriate.
Prompt 4 — apply delegation:
Review this class for places where Kotlin delegation would remove
boilerplate:
[paste code]
Consider class delegation (by) for interfaces and property delegates
like lazy or observable.
Kotlin Classes — Quiz
7 questions
Seven questions on constructors and properties, data classes, objects and companions, sealed hierarchies, and delegation.