Skip to content
teach

Modelling

Lookup sheet for stage 2: modelling a domain without reaching for a class-per-thing hierarchy first.

Choosing the construct

Need Reach for Because
Just storing data Data class Generates equality, toString, destructuring, copy for free
A fixed, closed set of variants, each possibly carrying different data Sealed class/interface Compiler knows every subclass; when is exhaustive without else
A fixed set of same-shape constants (one singleton instance each) Enum Real objects, each can hold its own state/constructor args
Exactly one instance, globally accessible object declaration Thread-safe lazy singleton, built into the language
Java's static members Companion object Kotlin has no static; the companion is a real object, not just a namespace
Shared behavior across otherwise-unrelated types Interface with default methods Composition instead of forcing a shared base class
Real behavior beyond storing values, that none of the above fit A plain class The Kotlin docs' own last resort, not the default starting point

Properties replace getter/setter boilerplate

class Person(val name: String, var age: Int) generates real properties (person.name, person.age = 30) backed by default getters/setters, no getName()/setAge() to write. A custom accessor keeps the call site identical:

val area: Int get() = width * height                 // computed on every access
var name: String = ""
    set(value) { field = value.trim() }               // field = the backing field

Referring to the property name itself inside its own accessor recurses infinitely; field is the only way to reach the backing storage.

A primary-constructor parameter without val/var is not a property: class Point(x: Int, y: Int) { val doubled = x * 2 } exposes point.doubled but not point.x.

Data classes

data class User(val name: String, val age: Int) generates, from primary-constructor properties only:

Generated Does
equals()/hashCode() Structural equality
toString() "User(name=John, age=42)"
componentN() Destructuring: val (name, age) = user
copy() New instance with named properties changed, rest identical

A property declared in the class body (not the primary constructor) is invisible to all four. data class Person(val name: String) { var age: Int = 0 }: two instances differing only in age are still ==-equal, since age never participates.

Requirements: at least one primary-constructor parameter, all marked val/var; cannot be abstract, open, sealed, or inner; custom equals()/hashCode()/toString() are allowed (the compiler skips generating them), but custom componentN()/copy() are disallowed entirely, keeping them mechanically tied to the real current property list.

All-val data classes plus copy() give immutability with practical "updates": user.copy(age = user.age + 1) produces a new instance without re-specifying every other property or reaching for var.

Sealed classes: exhaustiveness the compiler checks

Every direct subclass of a sealed class/sealed interface must be declared in the same module and package, so the compiler knows the complete, closed set of subclasses. A when over a sealed type is exhaustive without else as long as every subclass has a branch:

flowchart LR A["sealed class Result:
Loading, Success, Failure"] --> B["when(result) matches
all 3, no else needed"] A --> C["new subclass Cancelled
added later"] C --> D["existing when, still
missing Cancelled:
fails to compile"]

Adding a new subclass later makes every non-else when over that type that doesn't handle it fail to compile immediately, at the exact call site that needs updating. Wrong fit: a hierarchy genuinely meant to be extended by outside code (a plugin system), since sealing forbids exactly that.

Enums: real objects, not bare labels

Each constant is an actual instance, so it can carry its own constructor arguments (enum class Color(val rgb: Int) { RED(0xFF0000), ... }) and even override abstract members per-constant in an anonymous class body, giving different constants different behavior for the same method call. Every enum gets entries (ordered List of constants), valueOf(name), and per-constant name/ordinal for free, plus Comparable ordered by declaration order.

Enum vs sealed class:

flowchart TD A["modelling a fixed
set of variants"] --> B{"all the same shape,
one singleton instance each?"} B -- "yes" --> C["enum"] B -- "no, different data
shapes per variant" --> D["sealed class"]

Awkward, only-relevant-to-some-constants extra fields on an enum are the signal that the variants actually need a sealed class instead.

Object declarations and companion objects

object Name { ... } defines a class and creates its single instance in one step: Kotlin guarantees the initialization is thread-safe and lazy (on first access), with no hand-written double-checked locking. Reference it by name directly: DataProviderManager.registerDataProvider(...).

Kotlin has no static keyword; a companion object (at most one per class, class X { companion object { ... } }) is what replaces Java's static members, accessed the same way (MyClass.create()). Unlike a Java static member, a companion object is a real object: it can implement interfaces, have its own supertype, and be passed around as a value. Common use: a factory function (User.create("Ada")) that can validate, cache, or choose a subclass in ways a plain constructor can't.

Interfaces with default methods: behavior yes, state no

An interface can implement method bodies and provide computed property accessors (val x: String get() = "foo"), but can never hold a backing field, regardless of val/var. That is the precise line between an interface and an abstract class (which can hold real state).

The diamond conflict is a compile error, not a silent pick: if a class implements two interfaces that each default-implement the same method, Kotlin forces an explicit override:

class D : A, B {
    override fun foo() { super<A>.foo(); super<B>.foo() }
}

super<Type>.method() names which interface's implementation to call. Silently preferring one parent's implementation (as some multiple-inheritance languages do) would hide a real decision inside code that merely compiles, which is exactly what this rule refuses to allow.

Table of contents