Classes, data classes and extensions
Primary constructors, inheritance and interfaces, then the language features that remove boilerplate: data classes, sealed types and extension functions.
Classes and interfaces
interface Notifier {
fun send(message: String)
fun label(): String = this::class.simpleName ?: "notifier" // default body
}
open class Account(val owner: String, opening: Long = 0) {
var balance: Long = opening
private set // readable, not writable outside
init {
require(opening >= 0) { "opening must not be negative" }
}
open fun deposit(amount: Long) {
require(amount > 0) { "amount must be positive" }
balance += amount
}
}
class Savings(owner: String, opening: Long) : Account(owner, opening) {
override fun deposit(amount: Long) {
super.deposit(amount)
}
}- Classes are
finalby default; mark a classopenonly if it is designed for inheritance. - Properties declared in the primary constructor become fields with implied getters, and
varones also get setters. - A custom setter with a narrower visibility (
private set) is the idiomatic way to expose read-only state that the class still mutates.
Data classes and sealed types
data class Point(val x: Int, val y: Int)
val p = Point(1, 2)
val q = p.copy(y = 5) // Point(x=1, y=5)
val (x, y) = p // destructuring
println(p) // Point(x=1, y=2) - generated toString
sealed interface Shape {
data class Circle(val radius: Double) : Shape
data class Rect(val width: Double, val height: Double) : Shape
}
fun area(shape: Shape): Double = when (shape) {
is Shape.Circle -> Math.PI * shape.radius * shape.radius
is Shape.Rect -> shape.width * shape.height
} // no else needed: the compiler proves the branches are exhaustiveA data class must have at least one constructor parameter and generates equals, hashCode, toString, copy and component functions from exactly those parameters. A sealed hierarchy restricts subtypes to the same module, which is what makes exhaustive when possible.
Extension functions and scope functions
// add a function to a type you do not own, without inheritance
fun String.titleCase(): String =
split(' ').joinToString(" ") { it.lowercase().replaceFirstChar(Char::uppercaseChar) }
fun <T> List<T>.secondOrNull(): T? = if (size > 1) this[1] else null
"hello world".titleCase() // "Hello World"
listOf(1, 2, 3).secondOrNull() // 2
// inside the extension, this is the receiver
fun Account.describe(): String = owner + " has " + balance| Function | Receiver is | Returns |
|---|---|---|
let | it | The lambda result |
run | this | The lambda result |
with | this | The lambda result |
apply | this | The receiver, for configuration |
also | it | The receiver, for side effects |
💡
Extensions are resolved statically, so they are not virtual: if a class declares a member function with the same signature, the member always wins and the extension is never called. Use them for convenience, not for polymorphism.
FAQ
Why is my data class equality failing?
Only constructor parameters are used by the generated
equals. Anything set in the body is ignored, so two instances can be equal while holding different extra state.apply or also?
apply when you are configuring the receiver and want to return it, with this in scope. also when you want a side effect such as logging and prefer an explicit it.Related
Syntax and null safety Coroutines and collections
Last refreshed 2026-09-18.