Everything else is just a tool to model your domain in a way that’s easier to read and reason about, and to produce much shorter code. Interfaces define module boundaries and can be mocked to enable simpler testing. Through classes, objects, and data structures, you can model your domain entities. Enums make a list of limited options crystal clear. Extensions allow you to add functionality to existing types, without having access to their original source code, giving advantage of better logic structure. With generics, you can write universal code, promoting reuse. And overloaded operators give that extra edge to common operations that benefit from succinct, crisp syntax.

Custom data types

Custom types that focus on plain data. These allow you to model the information you work with. Structure and group data, and define relationships between entities. In other languages, they may be called “structs” or “records” and may behave as value types. In Kotlin, they are reference types, same as object types, but with some limitations and advantages.

Custom data types

A data class is based on properties. You could understand it as a structured collection of properties.

Properties can be defined in the primary constructor – need to be specified when the object is created –, or in the class body. Only the primary properties participate in comparison and synthesized functions like copy(), toString() or componentN().

data class Person(
  val name: String,
  var age: Int
) {
  var favoriteColor: String? = null
}

Person("Simon", 30) != Person("Simon", 40)
Person("Simon", 30) == Person("Simon", 30).apply { favoriteColor = "blue" }

Custom data types can also be optional. They have a string representation out of the box and can be decomposed into their primary properties.

var maybePerson: Person? = Person("Simon", 30)
maybePerson?.favoriteColor = "red"

val newcomer = Person("Simon", 30).toString()
val greeting = "Say hello to: $newcomer"

val (name, age) = Person("Simon", 30)

Member-wise equality and hashing behavior is provided automatically, but can be overridden. As well as, toString().

data class PersonWithID(val id: Int, val name: String, var age: Int) {
  override fun equals(other: Any?): Boolean {
    if (other !is PersonWithID) return false
    return id == other.id
  }

  override fun hashCode(): Int = id

  override fun toString(): String = "Person $id"
}

PersonWithID(1, "Simon", 30) == PersonWithID(1, "Renamed", 40)

Data classes cannot be extended and cannot be abstract, but can extend other classes and implement interfaces. Their parents can define equality, hashing, and string representation behaviors via final overrides!

abstract class IdentifiableEntity {
  abstract val id: Int

  final override fun equals(other: Any?): Boolean {
    if (other !is IdentifiableEntity) return false
    return id == other.id
  }

  final override fun hashCode(): Int = id
}

data class Citizen(
  override val id: Int, 
  val name: String, 
  var age: Int
): IdentifiableEntity()

Citizen(1, "Simon", 30) == Citizen(1, "Renamed", 40)

Data classes support cloning with partial modification via synthesized copy() method, to enable immutable data model. Since data classes are reference types, it would be easy for a function to modify a var property of a passed data class argument. This often causes sneaky types of bugs. Declaring properties read-only and using copy() to modify data when needed prevents the problem.

Overriding copy() or componentN() (decomposition) is not allowed.

val person = PersonWithID(1, "Simon", 30)
val renamedPerson = person.copy(name = "Renamed")
val changedPerson = renamedPerson.copy(name = "Renamed again", age = 40)

Standard, generic data classes Pair and Triple are available.

val pair: Pair<String, Int> = "Simon" to 30
val triple = Triple(1, "Simon", 30)

[--]