Polyglot
Languages Kotlin data structures
Kotlin § data-structures

Data classes and objects

Kotlin’s distinctive data-modelling features: data classes (auto-generated equals, hashCode, toString, copy, componentN), object declarations (singletons), companion objects (class-level members), enum classes (with associated data and methods), and the substantial standard collections (List, MutableList, Set, MutableSet, Map, MutableMap). The collections distinguish read-only (covariant) and mutable (invariant) interfaces — substantial type safety. The combination — data classes for value records, object/companion for singletons, the rich collection library, the substantial Map / List / Set interface, the read-only/mutable distinction — is the substance of Kotlin’s data-modelling surface.

Data classes

The data class admits auto-generated record-like behaviour:

data class Person(val name: String, val age: Int)

val alice = Person("Alice", 30)
val bob = Person("Bob", 25)

// Auto-generated:
println(alice)                                     // "Person(name=Alice, age=30)"
println(alice == Person("Alice", 30))             // true (value equality)
println(alice.hashCode())                          // hash code
val copy = alice.copy(age = 31)                    // copy with modification
val (name, age) = alice                            // destructuring

The compiler synthesises:

  • equals(other: Any?): Boolean — value equality based on properties.
  • hashCode(): Int — hash based on properties.
  • toString(): String — human-readable representation.
  • copy(...) — admits property updates.
  • componentN() methods (component1(), etc.) — admit destructuring.

The principal restrictions:

  • Primary constructor must have at least one parameter.
  • Primary constructor parameters must be val or var.
  • Cannot be abstract, open, sealed, or inner.

The conventional Kotlin discipline favours data class for value-typed records.

copy() for immutable updates

data class User(val name: String, val age: Int, val email: String)

val u = User("Alice", 30, "alice@b.c")
val updated = u.copy(age = 31)                     // changes only age
val renamed = u.copy(name = "Alice Smith")

The form admits substantial immutable update patterns.

Destructuring

val (name, age) = alice

// In for loops:
for ((name, age) in users) {
    println("$name: $age")
}

// In lambdas:
users.forEach { (name, age) -> println("$name: $age") }

// Selective destructuring:
val (_, age) = alice                               // ignore name

Destructuring is positional — the order of properties in the primary constructor matters.

object declarations

Singletons:

object DatabaseConnection {
    private val connectionString = "jdbc:postgres:..."
    private var connection: Connection? = null

    fun connect(): Connection {
        return connection ?: createConnection().also { connection = it }
    }

    fun close() {
        connection?.close()
        connection = null
    }

    private fun createConnection(): Connection { /* ... */ }
}

DatabaseConnection.connect()
DatabaseConnection.close()

The object admits a single instance — initialised on first access; thread-safe by default.

For namespacing constants:

object Config {
    const val MAX_RETRIES = 3
    const val DEFAULT_TIMEOUT = 30
    const val APP_NAME = "MyApp"

    object URLs {
        const val API = "https://api.example.com"
        const val DOCS = "https://docs.example.com"
    }
}

Config.MAX_RETRIES
Config.URLs.API

Object declarations may extend classes and implement interfaces:

object Logger : Closeable {
    override fun close() { /* ... */ }

    fun log(message: String) { /* ... */ }
}

Companion objects

Inside a class, companion object admits class-level members:

class User private constructor(val id: Int, val name: String) {
    companion object {
        const val MAX_NAME_LENGTH = 100

        fun create(name: String): User {
            require(name.length <= MAX_NAME_LENGTH)
            return User(generateId(), name)
        }

        private fun generateId(): Int = (Math.random() * 1_000_000).toInt()
    }
}

User.MAX_NAME_LENGTH
User.create("Alice")

Treated in Classes and OOP.

Anonymous objects

For one-off implementations:

val handler = object : Runnable {
    override fun run() {
        println("running")
    }
}

// With multiple interfaces:
val listener = object : ClickListener, KeyListener {
    override fun onClick(event: Event) { /* ... */ }
    override fun onKey(event: Event) { /* ... */ }
}

button.setOnClickListener(handler)

For SAM (single abstract method) interfaces, lambdas are conventionally clearer:

button.setOnClickListener { event -> handle(event) }

Enum classes

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

val d: Direction = Direction.NORTH
println(d.name)                                    // "NORTH"
println(d.ordinal)                                 // 0

// All values:
Direction.values()                                 // Array<Direction>
Direction.entries                                  // List<Direction> (since 1.9)

// From string:
Direction.valueOf("NORTH")                         // Direction.NORTH

Enum with associated data

enum class Planet(val mass: Double, val radius: Double) {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS(4.869e+24, 6.0518e6),
    EARTH(5.976e+24, 6.37814e6),
    MARS(6.421e+23, 3.3972e6);

    val surfaceGravity: Double
        get() = G * mass / (radius * radius)

    companion object {
        const val G = 6.67300E-11
    }
}

println(Planet.EARTH.surfaceGravity)               // 9.80...

The enum admits properties, methods, and companion objects.

Enum with abstract method

enum class Operation {
    PLUS {
        override fun apply(a: Int, b: Int) = a + b
    },
    MINUS {
        override fun apply(a: Int, b: Int) = a - b
    },
    TIMES {
        override fun apply(a: Int, b: Int) = a * b
    };

    abstract fun apply(a: Int, b: Int): Int
}

Operation.PLUS.apply(3, 5)                         // 8

The pattern admits substantial polymorphism per case.

Collections

Kotlin distinguishes read-only and mutable collections:

Read-onlyMutable
List<T>MutableList<T>
Set<T>MutableSet<T>
Map<K, V>MutableMap<K, V>

The read-only interfaces admit covariance (List<out T>); the mutable interfaces are invariant.

List

val list: List<Int> = listOf(1, 2, 3)              // read-only
val mutable: MutableList<Int> = mutableListOf(1, 2, 3)
mutable.add(4)
mutable.remove(1)
mutable[0] = 99

// list.add(4)                                     // ERROR

// Read operations:
list[0]
list.first()
list.last()
list.size
list.isEmpty()
list.contains(2)
list.indexOf(2)

Set

val set: Set<String> = setOf("apple", "banana", "apple")  // {apple, banana}
val mutable: MutableSet<String> = mutableSetOf("a", "b")
mutable.add("c")
mutable.remove("a")

// Set operations:
val a = setOf(1, 2, 3)
val b = setOf(2, 3, 4)

a union b                                          // {1, 2, 3, 4}
a intersect b                                      // {2, 3}
a subtract b                                       // {1}

Map

val map: Map<String, Int> = mapOf("a" to 1, "b" to 2)
val mutable: MutableMap<String, Int> = mutableMapOf("a" to 1)
mutable["b"] = 2
mutable.remove("a")

// Read:
map["a"]                                           // Int? (returns null if missing)
map.getOrDefault("c", 0)
map.getValue("a")                                  // throws if missing
map.keys
map.values
map.entries

// Iteration:
for ((key, value) in map) {
    println("$key: $value")
}

Other collection types

val arrayList = ArrayList<Int>(initialCapacity = 100)  // explicit ArrayList
val linkedList = LinkedList<Int>()                  // java.util.LinkedList
val arrayDeque = ArrayDeque<Int>()                  // double-ended queue
val hashMap = HashMap<String, Int>()
val linkedHashMap = LinkedHashMap<String, Int>()    // preserves insertion order
val treeMap = TreeMap<String, Int>()                // sorted by key

val intArray = IntArray(10) { 0 }                   // primitive int array
val intArray = intArrayOf(1, 2, 3)

Tuples

Kotlin admits Pair and Triple for ad-hoc tuples:

val pair: Pair<String, Int> = Pair("Alice", 30)
val pair = "Alice" to 30                           // infix `to`

val (name, age) = pair                             // destructuring

val triple = Triple("Alice", 30, "alice@b.c")
val (name, age, email) = triple

For substantial multi-field returns, data classes are conventionally clearer.

Common patterns

Data class for a record

data class User(
    val id: Int,
    val name: String,
    val email: String,
    val role: Role = Role.USER
)

val u = User(1, "Alice", "alice@b.c")
val admin = u.copy(role = Role.ADMIN)

Data class for state

data class GameState(
    val level: Int = 1,
    val score: Int = 0,
    val livesRemaining: Int = 3,
    val isPaused: Boolean = false
) {
    val isOver: Boolean get() = livesRemaining == 0
}

Builder pattern via copy

data class HttpRequest(
    val url: String,
    val method: String = "GET",
    val headers: Map<String, String> = emptyMap(),
    val body: String? = null
)

val base = HttpRequest("https://example.com")
val authed = base.copy(headers = mapOf("Authorization" to "Bearer $token"))
val post = authed.copy(method = "POST", body = jsonString)

Object as namespace

object Config {
    const val APP_NAME = "MyApp"
    const val VERSION = "1.0.0"

    object Server {
        const val HOST = "localhost"
        const val PORT = 8080
    }

    object Limits {
        const val MAX_USERS = 1000
        const val MAX_REQUESTS_PER_HOUR = 60
    }
}

Singleton service

object AnalyticsService {
    private val events = mutableListOf<Event>()

    fun track(event: Event) {
        events.add(event)
        send(event)
    }

    private fun send(event: Event) { /* ... */ }
}

AnalyticsService.track(Event.PageView("/home"))

Enum with helper methods

enum class Status {
    ACTIVE, INACTIVE, BANNED;

    val isAllowed: Boolean
        get() = this == ACTIVE

    companion object {
        fun fromString(s: String): Status? = entries.firstOrNull { it.name == s }
    }
}

Status.ACTIVE.isAllowed                            // true
Status.fromString("ACTIVE")                        // Status.ACTIVE

Map with default

val counts = mutableMapOf<String, Int>()
for (word in words) {
    counts[word] = (counts[word] ?: 0) + 1
}

// Or:
val counts = words.fold(mutableMapOf<String, Int>()) { acc, word ->
    acc[word] = (acc[word] ?: 0) + 1
    acc
}

// Or:
val counts = words.groupingBy { it }.eachCount()

Group-by

val grouped = users.groupBy { it.department }
val byAge = users.groupBy {
    when {
        it.age < 18 -> "minor"
        it.age < 65 -> "adult"
        else -> "senior"
    }
}

Set operations

val required = setOf("name", "email", "password")
val provided = setOf("name", "email")
val missing = required - provided                  // {"password"}

// Or:
val missing = required.subtract(provided)

List manipulation

val list = listOf(1, 2, 3, 4, 5)

list.first()                                       // 1
list.last()                                        // 5
list.first { it > 2 }                              // 3
list.firstOrNull { it > 100 }                      // null

list.take(3)                                       // [1, 2, 3]
list.takeLast(2)                                   // [4, 5]
list.drop(2)                                       // [3, 4, 5]
list.dropLast(2)                                   // [1, 2, 3]

list.reversed()
list.sorted()
list.distinct()

list + 6                                           // [1, 2, 3, 4, 5, 6] (new list)
list - 3                                           // [1, 2, 4, 5]
list + listOf(6, 7)                                // concatenation

MutableList

val mutable = mutableListOf(1, 2, 3)
mutable.add(4)
mutable.add(0, 0)                                  // insert at index
mutable.remove(2)
mutable.removeAt(0)
mutable.removeAll { it > 3 }
mutable.clear()

Map iteration

for ((key, value) in map) {
    println("$key: $value")
}

map.forEach { (key, value) -> println("$key: $value") }

// Sorted by key:
for ((key, value) in map.toSortedMap()) {
    println("$key: $value")
}

// Filter:
val positive = map.filter { (_, v) -> v > 0 }

Array vs List

// Array<T> — boxed Java Object array (for object types):
val arr: Array<Int> = arrayOf(1, 2, 3)             // Integer[] under the hood
val arr: Array<String> = arrayOf("a", "b")

// Primitive arrays — unboxed:
val intArr: IntArray = intArrayOf(1, 2, 3)         // int[]
val doubleArr: DoubleArray = doubleArrayOf(1.0, 2.0)
val byteArr: ByteArray = byteArrayOf(0x01, 0x02)

// List<T> — interface with multiple implementations:
val list: List<Int> = listOf(1, 2, 3)              // ArrayList by default
val mutable: MutableList<Int> = mutableListOf(1, 2, 3)

The conventional discipline:

  • Use List/MutableList for routine sequences.
  • Use IntArray/DoubleArray etc. for substantial primitive arrays (no boxing).
  • Use Array<T> rarely — for Java interop or generic array needs.

Nested data class

data class Address(
    val street: String,
    val city: String,
    val country: String
)

data class Person(
    val name: String,
    val age: Int,
    val address: Address
)

val p = Person("Alice", 30, Address("Main St", "Helsinki", "FI"))
val moved = p.copy(address = p.address.copy(city = "Stockholm"))

Sealed type hierarchy

sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: String) : Result<Nothing>()
    object Pending : Result<Nothing>()
}

Treated in Sealed types.

Range

val range: IntRange = 1..10                        // inclusive
val range: IntRange = 1 until 10                   // exclusive
val range: CharRange = 'a'..'z'

range.contains(5)
range.first
range.last

for (i in 1..10) print(i)

A note on componentN() for destructuring

The componentN() methods admit destructuring; auto-generated for data class:

data class Point(val x: Double, val y: Double)

// Auto-generated:
// operator fun component1(): Double = x
// operator fun component2(): Double = y

val p = Point(1.0, 2.0)
val (x, y) = p                                     // calls component1, component2

For non-data classes, manual operator fun component1() admits destructuring.

A note on the conventional discipline

The contemporary Kotlin data-structures advice:

  • Use data class for value-typed records.
  • Use copy() for immutable updates.
  • Use object for singletons and namespaces.
  • Use companion object for class-level members.
  • Use enum class with properties and methods.
  • Use List/MutableList by default for sequences.
  • Use Map/MutableMap with substantial Map operations.
  • Use Set for unique-value collections.
  • Use IntArray/DoubleArray for primitive arrays.
  • Use destructuring (val (a, b) = pair).
  • Use to (infix) for Pair creation in maps.
  • Trust read-only collection types — admit substantial type safety.

The combination — data classes for records, object/companion for singletons, enum classes with substantial features, the read-only/mutable collection distinction, the substantial standard collection library, the componentN-based destructuring — is the substance of Kotlin’s data-structure surface. The discipline produces concise, type-safe, expressive data modelling with substantial flexibility.