---
title: 'Data Classes'
source: 'https://academia.sh/en/courses/kotlin/data-classes'
course: 'The Kotlin Language'
language: en
updated: '2026-10-21T05:02:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a data class generates equals, hashCode, toString, and copy from the primary constructor'
    - 'Shows that a separate object built with copy falls to the content criterion'
    - 'Measures that a destructuring declaration corresponds to the componentN methods'
    - 'Verifies with the bounding measurement that the type of a field, whether an array or a list, determines the criterion'
---

# Data Classes

A measurement of the methods a data class generates from the properties in its primary constructor, of the separate object built with copy, and of how the type of a field affects the criterion.

The previous lesson showed that instances with the same values of a class without `equals` fall to the identity criterion, and that they move to content once `equals` and `hashCode` are written by hand. Writing those two methods by hand in every class is tedious and error-prone. Kotlin leaves this job to the compiler with the `data class` keyword: it generates the `equals`, `hashCode`, `toString`, `copy`, and `componentN` methods from the properties in the primary constructor. This lesson measures what the generated methods do and how they build the criterion.

The measurement axis stays the same: the relationship between a value and another value that counts as equal to it, the **criterion**. The `Criterion` class is given again below, as it was in the opening lesson. The rule works in order: if the first value and its twin are the same object, the criterion is singleton; if not, and the twin is not equal, it is identity; if the twin is equal but the third value is not, it is content; if the third value is equal too, it is partial content. This lesson measures the content slice.

```kotlin
// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}
```
## The Generated Methods

`data class Ticket(val id: Int, val owner: String)` is a single line and has no body. The block below tries the generated methods in turn. `println(ticket)` shows the generated `toString`. The two comparisons look at `equals`: against a separate `Ticket` with the same values, and against a `Ticket` with a different `owner`. The fourth line asks whether the `hashCode` values of two equal objects are equal; the value itself is not printed. The last two lines try `copy`: the copy built by changing `owner`, and the first object itself.

```kotlin
// Main.kt — generated methods
data class Ticket(val id: Int, val owner: String)

fun main() {
    val ticket = Ticket(7, "ada")
    println(ticket)
    println(ticket == Ticket(7, "ada"))
    println(ticket == Ticket(7, "can"))
    println(ticket.hashCode() == Ticket(7, "ada").hashCode())
    val moved = ticket.copy(owner = "can")
    println(moved)
    println(ticket)
}
```

```
Ticket(id=7, owner=ada)
true
false
true
Ticket(id=7, owner=can)
Ticket(id=7, owner=ada)
```

`toString` prints the fields with their names. `equals` finds the separate object with the same values equal and the one with a different `owner` not equal, and `hashCode` comes out equal for equal objects. `copy(owner = "can")` builds a separate object; the first object stays unchanged.

**TN15.** The measurement is for a small data class with two fields. Whether the generated methods behave the same in a class with many fields, or when one of the fields is another data class, does not follow from this block.

## Copy and the Criterion

Now the same methods are tied to the criterion. There are three lines. In the first, the first two of the three values are the same object. In the second, the twin is built with `ticket.copy()`, that is, with no field changed. In the third, the twin is built by calling the primary constructor directly. In every line, the third value is a `Ticket` whose `id` or `owner` differs.

```kotlin
// Main.kt — copy and the criterion
data class Ticket(val id: Int, val owner: String)

fun main() {
    val ticket = Ticket(7, "ada")
    val checks = listOf(
        Criterion("same-object", ticket, ticket, ticket.copy(id = 8)),
        Criterion("copy", ticket, ticket.copy(), ticket.copy(id = 8)),
        Criterion("constructor", ticket, Ticket(7, "ada"), Ticket(7, "can")),
    )
    for (check in checks) check.report()
    summarize(checks)
}
```

```
same-object: same-object=true, twin=true, other=false -> singleton
copy: same-object=false, twin=true, other=false -> content
constructor: same-object=false, twin=true, other=false -> content
singleton: 1
identity: 0
content: 2
partial-content: 0
```

In the first line the rule stops at the first question: because it is the same object, the criterion is singleton. In the second and third lines the twin is a separate object, `twin=true` comes out, and because the third value is not equal, the criterion is content. There is no difference in criterion between the object built with `copy()` and the object built with the constructor. `Coordinate` in the previous lesson, by contrast, gave identity in the same two lines. The difference is not in the construction route but in who wrote `equals`.

This lesson's distinction is here too: what is measured is not that `copy` exists as a method, but which criterion gives the equality. `copy` builds a separate object; that object being counted as equal comes from the generated `equals`.

**TN16.** The measurement is only for `Ticket`. `copy()` builds a shallow copy; whether two copies share a field when the field is a mutable object was not measured in this block.

## The Destructuring Declaration and componentN

The methods a data class generates, such as `component1` and `component2`, make the destructuring declaration possible: the line `val (id, owner) = ticket` assigns a `componentN` call to each name in order. The order is the parameter order in the primary constructor. The block below tries the declaration and two `componentN` calls. Then it asks which fields the generated methods look at: in the `Stamped` class, the `note` field is defined in the body, not in the primary constructor. Two `Stamped` objects are built with the same constructor values but different `note` values and compared.

```kotlin
// Main.kt — destructuring and componentN
data class Ticket(val id: Int, val owner: String)

data class Stamped(val id: Int, val owner: String) {
    var note: String = ""
}

fun main() {
    val ticket = Ticket(7, "ada")
    val (id, owner) = ticket
    println("$id $owner")
    println(ticket.component1())
    println(ticket.component2())
    val first = Stamped(1, "ada")
    first.note = "a"
    val second = Stamped(1, "ada")
    second.note = "b"
    println(first == second)
    println(first)
}
```

```
7 ada
7
ada
true
Stamped(id=1, owner=ada)
```

The declaration gives `7 ada`. `component1()` and `component2()` return the same values directly. `first == second` comes out `true`, even though the `note` fields are `a` and `b`. `toString` does not print `note` either. The generated methods look only at the properties in the primary constructor; a property in the body takes no part in equality or in the output.

**TN17.** This was measured only for a property defined with `var` in the body. Whether a computed property defined in the body, or a property inherited from a superclass, has a separate behavior was not measured.

## Copy Is Shallow

`copy` copies the fields, but if a field is a mutable collection, what gets copied is not the collection itself but the reference to it. In the block, `Basket` carries a `MutableList`. When an item is added to the first copy, is the first object affected? In the second attempt the list is copied explicitly and the same question is asked again.

```kotlin
// Main.kt — copy is shallow
data class Basket(val owner: String, val items: MutableList<String>)

fun main() {
    val first = Basket("ada", mutableListOf("apple"))
    val second = first.copy(owner = "can")
    second.items.add("pear")
    println(first.items)
    println(second.items)
    println(first.items === second.items)
    val deep = first.copy(items = first.items.toMutableList())
    deep.items.add("cherry")
    println(first.items)
    println(deep.items)
}
```

```
[apple, pear]
[apple, pear]
true
[apple, pear]
[apple, pear, cherry]
```

The first two lines of the output are the same, `[apple, pear]`. The `pear` added through `second` is visible inside `first` too. The third line says why: `first.items === second.items` gives `true`, so the two objects go to the same list. `copy` copies the fields one by one, but for the list field, what is copied is not the list itself but the reference to it. The copying is shallow.

In the last two lines the list is copied explicitly with `toMutableList()`. This time the `cherry` added to `deep` is not visible in `first`. A common mistake is to take `copy` for a fully independent copy. In a data class that carries a mutable collection, a `copy` call preserves the equality semantics on the one hand, and on the other makes the two objects owners of the same state. The fix is either to make the field a read-only `List` or to copy the collection explicitly in the `copy` call. The content criterion holds here too: the two objects are equal as far as `equals` is concerned, but when one changes, the other changes as well.

## Use as a Key and a Common Mistake

The generated `hashCode` makes a data class usable as a map key and a set element. A lookup with a separate object with the same values finds it. But if a data class with a `var` field is modified after it enters a set, its hash code changes and the object is separated from the place where it sits in the set. The block first reads from a map with a key with the same values, then changes the field of an object placed in a set and repeats the lookup.

```kotlin
// Main.kt — a data class as a key
data class Ticket(val id: Int, var owner: String)

fun main() {
    val prices = hashMapOf(Ticket(1, "ada") to 10)
    println(prices[Ticket(1, "ada")])
    val key = Ticket(2, "can")
    val set = hashSetOf(key)
    println(set.contains(key))
    key.owner = "eda"
    println(set.contains(key))
    println(set.size)
    println(set.contains(Ticket(2, "can")))
    println(set.any { it == key })
}
```

```
10
true
false
1
false
true
```

Reading from the map gives `10`: a separate `Ticket` with the same values is found as a key, because equality and the hash code come from the field values. For the object placed in the set, `contains` first returns `true`. After `key.owner` is changed, a lookup with the same object becomes `false`, but `size` is still `1`: the object is in the set, it just cannot be found. The `Ticket(2, "can")` built with the old value cannot be found either, because the element in the set now carries the owner `eda` and is not equal. A linear `any` scan, on the other hand, finds the object.

The reason for the mechanism is this: a hash-based collection places an element according to the hash code it had at the moment it was added. When the field changes, the code changes and the lookup looks somewhere else. A common mistake is to leave a `var` field in a data class used as a key or a set element. The rule: the fields that enter equality for an object placed in a collection are not changed while that object is in the collection. Data classes that will be keys are written with `val` fields, and if a change is needed, a separate object is produced with `copy` and the old one is removed from the collection.

## Destructuring in Loops

The destructuring declaration works not only in `val` lines but also in `for` loops and lambda parameters. An unused position is skipped with `_`. Map entries are opened the same way, because they provide `component1` and `component2`. The block tries all of these forms.

```kotlin
// Main.kt — destructuring in a loop
data class Ticket(val id: Int, val owner: String)

fun main() {
    val tickets = listOf(Ticket(1, "ada"), Ticket(2, "can"))
    for ((id, owner) in tickets) println("$id=$owner")
    for ((_, owner) in tickets) println(owner)
    val (first) = tickets[1]
    println(first)
    val prices = mapOf("apple" to 3, "pear" to 5)
    for ((name, price) in prices) println("$name:$price")
    println(tickets.map { (id, _) -> id })
}
```

```
1=ada
2=can
ada
can
2
apple:3
pear:5
[1, 2]
```

The first two loops print `1=ada`, `2=can`, and then only the owner names; `_` skips the first position. The line `val (first) = tickets[1]` calls for attention: `first` is the value in the first position (`component1`), that is, the `id`, and the output is `2`, not the first ticket. In the map loop, `(name, price)` opens the entries with `component1` and `component2`. The lambda parameter in the last line is opened the same way and gives `[1, 2]`.

The defining trait of destructuring is that it depends on order, not on field names. The `componentN` functions are numbered by the parameter order in the primary constructor. A common mistake is to break the ordering when adding a field to a class; if a field is inserted between `id` and `owner` in the constructor, the old destructuring lines still compile but read a different field. For this reason fields are added only at the end, and destructuring is used only in small, stable data classes. For the rest, the `ticket.owner` form is more durable, because it relies on the meaning of the name, not on the order.

## Nested Data Classes

If a field of a data class is a data class too, the generated methods work in a nested way. `toString` opens the inner object as well, `equals` descends into the inner object's `equals`, and `copy` carries the inner object as it is. The block tries this with an inner field that can be `null`.

```kotlin
// Main.kt — nested data classes
data class Address(val city: String)
data class Person(val name: String, val address: Address?)

fun main() {
    val person = Person("ada", Address("izmir"))
    println(person)
    println(person == Person("ada", Address("izmir")))
    println(person.copy(address = null))
    println(person.copy(address = Address("ankara")) == person)
    println(Person("ada", null) == Person("ada", null))
}
```

```
Person(name=ada, address=Address(city=izmir))
true
Person(name=ada, address=null)
false
true
```

The generated `toString` opens the inner object as well: `Person(name=ada, address=Address(city=izmir))`. `equals` descends into the inner object's `equals`, so two separate `Person` objects with the same values come out equal (`true`). `copy(address = null)` changes only the specified field, and `null` is carried as a field without trouble; `toString` shows it as `address=null`. A copy built with a different city is not equal to the original object (`false`), while two people with a `null` address are equal (`true`).

This chained behavior holds as long as the inner object is a data class too. If the inner field is an ordinary class without `equals`, the equality of the outer object falls to the identity of that field: two outer objects with the same values again do not come out equal. A common mistake is to make only the outer class a data class and assume that this is enough for the whole tree. Equality is provided separately at every node of the tree. The content criterion of this lesson is carried to the outermost level only if the fields inside have the content criterion as well.

## The Bounding Measurement

The generated `equals` compares the fields with `==`. It follows that the type of a field should affect the criterion, according to the field's own `equals`. The bounding measurement compares two data classes with the same shape: `Packet` carries an `IntArray`, and `Message` carries the same numbers in a `List<Int>`. In both lines the twin is built separately with the same values, and the third value differs only in its `id`. The last line of the block asks something different: if two `Packet` objects share the same `IntArray` instance, are they equal?

```kotlin
// Main.kt — by field type
data class Packet(val id: Int, val bytes: IntArray)
data class Message(val id: Int, val bytes: List<Int>)

fun main() {
    val checks = listOf(
        Criterion("array", Packet(1, intArrayOf(1, 2)), Packet(1, intArrayOf(1, 2)), Packet(2, intArrayOf(1, 2))),
        Criterion("list", Message(1, listOf(1, 2)), Message(1, listOf(1, 2)), Message(2, listOf(1, 2))),
    )
    for (check in checks) check.report()
    summarize(checks)
    val shared = intArrayOf(1, 2)
    println(Packet(1, shared) == Packet(1, shared))
}
```

```
array: same-object=false, twin=false, other=false -> identity
list: same-object=false, twin=true, other=false -> content
singleton: 0
identity: 1
content: 1
partial-content: 0
true
```

Two classes with the same shape give two separate criteria. `Packet` does not come out equal when it is built with two separate arrays that have the same content: the array's `equals` looks at identity, and the `equals` the data class generates compares the field with this `equals`. `Message`, on the other hand, gives content, because the list's `equals` looks at the content. The last line confirms this: when the same array instance is shared, `Packet` comes out equal too. The generated method inherits the equality of the field type; the criterion is determined not by the data class but by the types of the fields.

**TN18.** The measurement is only for `IntArray` and `List<Int>`. That other array or collection types follow the same rule was not measured.

## Summary

- A `data class` generates `equals`, `hashCode`, `toString`, `copy`, and `componentN` from the properties in the primary constructor.
- `copy` builds a separate object; that object is counted as equal to the first one by the generated `equals`.
- The destructuring declaration corresponds to the `component1` and `component2` calls, and its order is the constructor order.
- Properties in the body do not enter the generated methods; the field type determines equality, with `IntArray` giving identity and `List<Int>` giving content.
- This lesson's criterion: **content**. In the bounding measurement, two data classes with the same shape fell to content and to identity depending on the field type.

## Next Step

Classes alone are not enough to express that a value belongs to only one of a few closed forms. The next lesson measures enumerations with `enum class` and sealed hierarchies with `sealed interface`: it looks at how an exhaustive `when` rejects a missing branch and at which criterion an enum entry falls to.
