A Kotlin Class Is Mostly Its Header

Declaring classes, primary and secondary constructors, property promotion, construction order, backing fields and custom accessors, private constructors, and why Kotlin classes are final by default. Part 1 of a two-part tour.

Most of a Kotlin program lives in its classes, and most of a Kotlin class lives in one line: the header. That header doubles as the constructor and declares your properties, which is why a class that would be thirty lines of Java often fits in one. The rest is a handful of conventions around setup, access, and inheritance — with a couple of sharp edges (construction order, backing fields) that repay knowing exactly. Everything here was run against Kotlin 2.4.10.

This is part one of two. Here we cover the everyday class; part two covers the specialized kinds — data, enum, sealed, object, value.

The smallest possible class

This is a complete, valid class:

class Person

No body, no braces. You create an instance by calling it like a function, and Kotlin has no new keyword:

val p = Person()

Properties: the heart of a class

A property is Kotlin’s version of a field plus its getter and setter, declared in one line:

class Person {
    var name: String = "Unknown"     // read-write: getter + setter
    val species: String = "Homo sapiens"   // read-only: getter only
}

You access both with a dot, and under the hood Kotlin generates the accessors. You rarely write them by hand — but they’re always there, which is what makes the custom-accessor and backing-field mechanics below possible.

The primary constructor and property promotion

Most classes need values at creation time. The primary constructor goes in the header:

class Person(name: String, age: Int)

The crucial detail: that declares constructor parameters, not properties, so name and age aren’t accessible after construction. Add val or var to promote them:

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

Now they’re real properties (p.name, p.age = 37). This one-line “declare and assign properties from the constructor” is what Java only recently approached with records; in Kotlin it’s the default way to write a class. A parameter without val/var is still usable during construction — it just isn’t retained:

class Circle(radius: Double) {
    val area = Math.PI * radius * radius   // radius used, then discarded
}

After construction there’s no radius property on the object.

Construction order: init blocks and initializers interleave

The primary constructor can’t hold statements, so setup logic goes in an init block. The subtlety worth pinning down: init blocks and property initializers run in the order they appear in the source, interleaved — not all initializers first, then all inits. Running this makes it concrete:

class Ordered(val name: String) {
    val a = "prop-init A".also(::println)
    init { println("init block 1") }
    val b = "prop-init B".also(::println)
    init { println("init block 2") }
}
// Ordered("x") prints, in this order:
//   prop-init A
//   init block 1
//   prop-init B
//   init block 2

That ordering matters because an init block or initializer can only reference properties declared above it — a property used before its own line is still uninitialized. When construction logic depends on ordering, this is the rule that governs it.

Two standard-library functions belong here: require throws IllegalArgumentException for bad arguments, and check throws IllegalStateException for bad state — both taking a lazy message lambda, and both idiomatic inside init:

class Person(val name: String) {
    init { require(name.isNotBlank()) { "name must not be blank" } }
}

Backing fields and custom accessors

You can give a property its own getter or setter. Inside a custom accessor, the special identifier field is the backing field — the actual storage — and using it is what tells the compiler to generate storage at all:

class Temperature(celsius: Double) {
    var celsius: Double = celsius
        set(value) {
            require(value >= -273.15) { "below absolute zero" }
            field = value              // assign the backing field, not `celsius` (that would recurse)
        }
    val fahrenheit: Double
        get() = celsius * 9 / 5 + 32   // no backing field — computed each read
}

Two distinct things are happening. celsius has a backing field and a validating setter — assign field, never celsius, or the setter calls itself forever. fahrenheit has no backing field, because its getter never references field; it’s a computed property, recomputed every read and always consistent with celsius. This getter/field split is the whole mechanism behind validation, lazy derivation, and the private set below.

Encapsulation: visibility and private set

Properties are public by default. A common refinement is publicly readable, privately writable, and you just annotate the setter:

class BankAccount(initial: Int) {
    var balance: Int = initial
        private set
    fun deposit(amount: Int) { balance += amount }   // allowed inside the class
}

Default and named arguments beat secondary constructors

Before reaching for more constructors, remember parameters take defaults, and combined with named arguments they replace Java’s overloaded-constructor pile:

class Server(val host: String = "localhost", val port: Int = 8080, val useTls: Boolean = false)

Server()                                    // all defaults
Server(port = 9090)                         // override one
Server(host = "example.com", useTls = true)

A secondary constructor exists for the rarer case, and must delegate to the primary with this(...):

class Person(val name: String) {
    var age: Int = 0
    constructor(name: String, age: Int) : this(name) { this.age = age }
}

In practice defaults make secondary constructors rare; reach for them mainly to satisfy frameworks that expect a specific constructor shape.

Private constructors and factories

A constructor can be private, which — paired with a factory in the companion object — forces all creation through a controlled entry point that can validate or normalize:

class User private constructor(val name: String) {
    companion object {
        fun of(name: String): User = User(name.trim())
    }
}
val u = User.of("  Ada  ")   // the only way to build one

Inheritance: classes are final by default

A deliberate difference from Java: a class cannot be subclassed unless you say so. Opt in with open:

open class Animal(val name: String) {
    open fun speak(): String = "..."
}
class Dog(name: String) : Animal(name) {
    override fun speak(): String = "Woof"
}

The base class is open, the overridable method is open, the override is marked override (the compiler enforces it), and the subclass calls the base constructor in its header (: Animal(name)). This “final by default” stance bakes a long-standing piece of OO advice — design for inheritance explicitly, or prohibit it — into the language. It’s also why so many of the specialized class kinds in part two are final and proud of it.

Abstract classes

When a base shouldn’t be instantiated and leaves some behavior unspecified, make it abstract:

abstract class Shape {
    abstract fun area(): Double                     // no body — subclasses provide it
    fun describe(): String = "Area is ${area()}"    // concrete, shared
}
class Square(val side: Double) : Shape() {
    override fun area(): Double = side * side
}

Abstract members are implicitly open, so you don’t repeat it, and an abstract class freely mixes abstract with concrete members — useful when subclasses share most behavior but differ in a few spots. (When there’s no shared state, an interface is usually the better tool — a distinction that chapter draws in full.)

Final thoughts

The plain class is a small set of rules that compose into most of the classes you’ll write: a header that doubles as a constructor, properties bundling storage with access, source-ordered construction, field for the storage behind a custom accessor, and explicit, opt-in inheritance. The two edges to keep sharp are construction order (initializers and init blocks interleave, top to bottom) and the backing field (field inside an accessor, never the property’s own name).

What makes Kotlin’s type system feel rich is the next layer — the specialized class kinds that bake whole behaviors in: structural equality with data, fixed sets with enum, closed hierarchies with sealed, singletons with object. That’s part two.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments