Classes & Objects

Constructors, properties, data classes, sealed hierarchies, objects instead of static, and delegation.

Constructors Properties data class sealed enum object by delegation Visibility
๐Ÿ—

Constructors

Every Kotlin class has at most one primary constructor, declared right in the class header instead of as a separate method body. It's not a shorthand bolted on top of a "real" constructor โ€” it's the main one, and its parameters can declare properties directly. Secondary constructors exist for cases the primary constructor can't express cleanly, but in idiomatic Kotlin they're the exception, not the default.

Primary constructor: parameters as properties Primary
// val/var on a constructor parameter makes it a property automatically:
// no separate field declaration, no assignment boilerplate
class User(val name: String, val age: Int)

val user = User("Ada", 36)
println(user.name) // Ada: name is a real, readable property

// Constructor parameters without val/var are just parameters:
// usable in init blocks, not exposed as properties
class Circle(radius: Double) {
    val area = Math.PI * radius * radius
    // "radius" itself isn't accessible outside the constructor
}

// Default values work here too, same as any function
class Connection(val host: String, val port: Int = 443)
init blocks & initialization order init
// init blocks run as part of the primary constructor, in the order
// they're declared, interleaved with property initializers
class Account(val owner: String, initialBalance: Int) {
    val balance: Int

    init {
        require(initialBalance >= 0) { "balance can't be negative" }
        balance = initialBalance
        println("Account created for $owner")
    }

    init {
        println("Second init block: still runs in declaration order")
    }
}
// Account("Ada", -5) throws IllegalArgumentException immediately
Secondary constructors Secondary
// Every secondary constructor must delegate to the primary,
// directly or through another secondary constructor, via this(...)
class Point(val x: Int, val y: Int) {
    constructor() : this(0, 0)                // origin
    constructor(both: Int) : this(both, both)  // on the diagonal
}

Point()      // (0, 0)
Point(5)     // (5, 5)
Point(1, 2)  // (1, 2)
๐Ÿ’ก
Reach for a secondary constructor only when Java interop or framework constraints demand it (some frameworks require a no-arg constructor to instantiate via reflection). For everything else, a default parameter value on the primary constructor does the same job with less code: class Point(val x: Int = 0, val y: Int = 0) replaces the whole example above.
๐Ÿน
Coming from Go: Go has no constructors at all โ€” you write a NewPoint(x, y int) *Point function by convention and hope every caller uses it instead of the zero-value struct literal. Kotlin's primary constructor is a real, enforced entry point: there's no way to construct a User that skips it.
๐Ÿ”ง

Properties & Custom Accessors

Kotlin has no separate concept of "a field with a getter and setter bolted on" โ€” every val/var declared as a class member is a property, and a property is really a pair of functions (a getter, and for var, a setter) with syntax that makes them look like plain field access. That means you can start with a simple stored property and later add validation or computed behavior in the accessor without changing a single call site.

Custom getters & setters get / set
class Rectangle(val width: Double, val height: Double) {
    // Computed property: no backing field, recalculated on every read
    val area: Double
        get() = width * height
}

class User(name: String) {
    // "field" refers to the auto-generated backing field;
    // without referencing it, there'd be no storage at all
    var name: String = name
        set(value) {
            require(value.isNotBlank()) { "name can't be blank" }
            field = value.trim()
        }
}

val u = User("  Ada  ")
println(u.name)     // "Ada": trimmed by the setter at construction time too
u.name = "Grace"  // goes through the same validation
โ„น๏ธ
The field identifier is only valid inside a property accessor, and it only exists if at least one accessor references it (or you don't override the default). A property with a custom getter and no backing field reference โ€” like area above โ€” allocates no storage at all; it's purely a computed function that happens to be called with property syntax.
๐Ÿน
Coming from Go: Go has no property syntax โ€” a getter is just a method, u.Name(), and there's no way to intercept a plain field assignment like u.name = "x" at all (Go fields are always direct storage access when exported). Kotlin properties let you start with the equivalent of a Go field and evolve it into validated, computed behavior later, without breaking callers who still write u.name = "x".
๐Ÿ“‡

Data Classes

A data class tells the compiler "this class's identity is defined entirely by its properties," and in exchange the compiler generates equals(), hashCode(), toString(), a copy() function, and destructuring support โ€” the exact boilerplate that Java developers used to reach for Lombok or an IDE generator to avoid writing by hand. It's the right tool for plain data carriers: request/response DTOs, value objects, immutable records. It's the wrong tool for classes with real identity or behavior beyond their data.

What data class generates data class
data class Point(val x: Int, val y: Int)

val p1 = Point(1, 2)
val p2 = Point(1, 2)

println(p1 == p2)       // true: structural equality, not reference identity
println(p1 === p2)      // false: different instances
println(p1)             // Point(x=1, y=2): readable toString() for free
println(p1.hashCode() == p2.hashCode()) // true: consistent with equals
copy(): change one field, keep the rest copy
data class User(val name: String, val age: Int)

val ada = User("Ada", 36)

// copy() creates a new instance, overriding only named fields
val olderAda = ada.copy(age = 37)
println(olderAda) // User(name=Ada, age=37)
println(ada)      // User(name=Ada, age=36): original unchanged
Destructuring declarations Destructuring
data class Point(val x: Int, val y: Int)

// Destructuring works because data classes generate
// component1(), component2(), ... automatically
val (x, y) = Point(3, 4)
println("$x, $y") // 3, 4

// Common in loops over pairs/maps
val pairs = listOf(1 to "one", 2 to "two")
for ((num, word) in pairs) {
    println("$num = $word")
}
โš ๏ธ
copy() is a shallow copy, and generated equals()/hashCode() only consider primary-constructor properties. A var property inside a data class is legal but dangerous: two instances can compare equal today and unequal tomorrow if a var field is mutated after construction, which silently breaks anything that hashed the object earlier (a HashMap key, a HashSet entry). Prefer val for every property on a data class.
๐Ÿ”

Sealed Classes & Interfaces

A sealed class or interface restricts its subtypes to a fixed set known at compile time, all declared in the same module. This is Kotlin's answer to the "sum type" or "tagged union" pattern from functional languages โ€” a value that is exactly one of a closed list of shapes, not an open-ended hierarchy anyone could extend. The payoff shows up at the point of use: an exhaustive when expression over a sealed type must cover every subtype, so adding a new one is a compile error everywhere it isn't yet handled, instead of a silent gap.

Modeling a closed set of outcomes sealed class
// Every possible outcome is a subtype; no other subtype can exist
sealed class NetworkResult
data class Success(val data: String) : NetworkResult()
data class Failure(val error: Throwable) : NetworkResult()
object Loading : NetworkResult()

fun render(result: NetworkResult): String = when (result) {
    is Success -> "Data: ${result.data}"   // smart-cast to Success
    is Failure -> "Error: ${result.error.message}"
    Loading    -> "Loading..."
    // no else needed โ€” and none allowed to compensate for a missing case
}
sealed interface: the same idea, without a base implementation sealed interface
// Useful when subtypes don't share a natural common base class,
// or a subtype already extends something else
sealed interface UiState
object Idle : UiState
data class Ready(val items: List<String>) : UiState
data class Error(val message: String) : UiState, Comparable<Error> {
    override fun compareTo(other: Error) = message.compareTo(other.message)
}
๐Ÿ’ก
Reach for sealed whenever you catch yourself modeling "one of these N specific things" with a string tag, an integer status code, or a boolean flag pair. The compiler-checked exhaustiveness is worth more than it looks: it means refactors that add a new case can't ship half-handled.
๐Ÿน
Coming from Go: Go has no sum types. The idiomatic Go equivalent is an interface with a small, "known" set of implementations and a type switch โ€” but nothing stops another package from adding a new implementation, and nothing forces the type switch to handle it. sealed plus exhaustive when gives you the compile-time guarantee Go's type switches can only get close to with discipline and code review.
๐ŸŽš

Enums

An enum class is a sealed class where every subtype is a named singleton instance, declared inline. Like sealed classes, they're exhaustively checkable in a when expression, but they go further: each constant can carry its own constructor arguments and even override behavior individually, which makes enums a natural fit whenever the "closed set of cases" also needs per-case data or logic.

Enum constants with constructor data enum class
enum class Planet(val massKg: Double, val radiusM: Double) {
    MERCURY(3.303e23, 2.4397e6),
    EARTH(5.976e24, 6.37814e6);

    // Regular member: available on every constant
    fun surfaceGravity(): Double {
        val g = 6.67300E-11
        return g * massKg / (radiusM * radiusM)
    }
}

println(Planet.EARTH.surfaceGravity())

// entries: a modern, allocation-free replacement for values()
for (planet in Planet.entries) {
    println("${planet.name}: ${planet.ordinal}")
}
Per-constant overrides Behavior per constant
// Each constant can override an abstract member with its own body
enum class Operation {
    PLUS {
        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
}

println(Operation.PLUS.apply(2, 3))  // 5
println(Operation.TIMES.apply(2, 3)) // 6
โ„น๏ธ
Prefer Planet.entries over the older Planet.values() in Kotlin 1.9+. entries is a cached List computed once; values() allocates a fresh array on every single call, which adds up in hot code paths that enumerate constants repeatedly.
๐Ÿงฟ

object & Companion Objects

Kotlin has no static keyword, because static members don't really belong to a class in an object-oriented sense โ€” they belong to nobody, floating outside any instance. Kotlin's answer is to make that intent explicit: object declares a class with exactly one instance, created lazily and thread-safely the first time it's used. A companion object is the same mechanism scoped inside another class, giving you a home for members that logically belong to the type itself rather than to any particular instance โ€” the closest equivalent to Java's static methods and fields.

object: a singleton, declared directly object
// Exactly one instance exists, created on first access
object AppConfig {
    var environment: String = "production"
    fun isDebug() = environment == "debug"
}

AppConfig.environment = "debug"
println(AppConfig.isDebug())  // true: same instance everywhere

// object can implement interfaces, useful for stateless implementations
interface Logger { fun log(msg: String) }
object ConsoleLogger : Logger {
    override fun log(msg: String) = println("[LOG] $msg")
}
companion object: static-like members on a class companion object
class User private constructor(val name: String) {
    companion object {
        // A factory function, called on the type itself: User.create(...)
        fun create(rawName: String): User? {
            val trimmed = rawName.trim()
            return if (trimmed.isBlank()) null else User(trimmed)
        }

        const val MAX_NAME_LENGTH = 100  // compile-time constant
    }
}

val user = User.create("  Ada  ")
println(User.MAX_NAME_LENGTH)  // 100

// A companion object is a real object with a name (default: Companion),
// and can implement interfaces, just like any object
๐Ÿ’ก
A private primary constructor plus a companion object factory function is the idiomatic Kotlin way to enforce validated construction โ€” the compiler won't let anyone call User(...) directly, only User.create(...), which can return null or throw on invalid input. It's a lighter-weight alternative to a separate builder class.
๐Ÿน
Coming from Go: Go's package-level functions and vars already give you "belongs to the package, not an instance" โ€” Kotlin's companion object is the same idea scoped to a single class instead of a whole file. The `object` singleton pattern replaces the common Go idiom of a package-level var globalInstance = &Thing{} guarded by a sync.Once, except Kotlin's compiler and runtime handle the thread-safe lazy init for you.
๐Ÿค

Interface Delegation: by

Implementing an interface usually means writing every one of its methods yourself, even when a member object already implements it perfectly and you just want to forward calls. The by keyword generates that forwarding boilerplate for you: the class declares that it implements the interface "by" delegating to an existing instance, and the compiler writes every method as a one-line call to that instance. It's composition dressed up with the ergonomics of inheritance.

Delegating an interface to a held instance by
interface SoundSource {
    fun play(): String
}

class Speaker : SoundSource {
    override fun play() = "playing through speaker"
}

// MediaPlayer implements SoundSource entirely by forwarding to `source`.
// No play() body needed here unless you want to override it.
class MediaPlayer(source: SoundSource) : SoundSource by source

val player = MediaPlayer(Speaker())
println(player.play()) // "playing through speaker"

// You can still override individual members selectively
class LoudPlayer(source: SoundSource) : SoundSource by source {
    override fun play() = "${source.play()} at max volume"
}
๐Ÿ’ก
Delegation is Kotlin's answer to the classic "favor composition over inheritance" advice: you get the caller-facing convenience of subclassing an implementation without the fragile-base-class problems that come from actually extending a concrete class. It's especially common when wrapping a third-party implementation to add or intercept a handful of methods, like the LoudPlayer example.
๐Ÿ”Ž

Visibility Modifiers

Kotlin adds a fourth visibility level beyond Java's three. public is the default โ€” unlike Java, where leaving off a modifier means package-private. internal is new: visible anywhere in the same module (a Gradle module or IntelliJ module boundary), which gives you a visibility level between "this file" and "the whole world" that Java has no direct equivalent for.

The four levels Visibility
class BankAccount(private val pin: String) {

    public val accountId: String = generateId()
    // public is the default: "public val accountId" and "val accountId"
    // mean exactly the same thing

    private var balance: Int = 0
    // visible only inside this class

    protected open fun auditLog(): String = "account $accountId"
    // visible in this class and subclasses only

    internal fun rawBalance() = balance
    // visible anywhere in the same module, not to external consumers
    // of a published library โ€” Java has no equivalent to this level
}
โ„น๏ธ
At the top level of a file (functions and properties not inside any class), protected isn't legal โ€” there's no subclass relationship to scope it to. private at file scope means "visible only within this file," which is a handy way to hide helper functions from the rest of the module without wrapping them in a class.
๐Ÿ“

Nested vs Inner Classes

A class declared inside another class is, by default, a plain nested class with no connection to any specific outer instance โ€” it's just namespaced inside the outer class, like Java's static nested classes. Marking it inner changes that: an inner class holds an implicit reference to the outer instance that created it, and can access the outer instance's members directly. This is the reverse of Java's default, where a non-static inner class is the common case and you opt out with static.

Default nested: no outer instance link Nested
class Outer(val name: String) {
    // Nested class: cannot see Outer's members, needs no Outer instance
    class Nested {
        fun greet() = "hello from a nested class"
    }
}

// Constructed without any Outer instance
val n = Outer.Nested()
println(n.greet())
inner: holds a reference to its outer instance inner
class Outer(val name: String) {
    inner class Inner {
        // Can reach Outer's members directly, because this Inner
        // instance is tied to the Outer instance that created it
        fun describe() = "inner class of $name"

        // this@Outer disambiguates when both classes share a member name
        fun outerName() = this@Outer.name
    }
}

// Must be constructed through an Outer instance
val outer = Outer("config")
val inner = outer.Inner()
println(inner.describe()) // "inner class of config"
โš ๏ธ
Every inner instance keeps its outer instance alive through that implicit reference, which is a real memory-leak risk if the inner instance outlives the outer one's intended lifetime (a classic Android example: an inner class used as a long-lived callback, holding an Activity in memory after it should have been garbage collected). Default to a plain nested class unless you specifically need the outer instance's state or behavior.
๐Ÿน
Coming from Go: Go has no nested types at all โ€” a type declared inside a function is a local type with no name visible outside it, and there's no notion of one struct being scoped inside another's namespace. Kotlin's default nested class is the closer analogue to "just group related types together," while inner is closer to a closure that captures a receiver.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Primary constructor propertyclass User(val name: String)val/var on a param generates a property
init blockinit { ... }Runs in declaration order with property initializers
Secondary constructorconstructor(x: T) : this(...)Must delegate to the primary constructor
Custom getterval x: T get() = ...No backing field unless referenced via `field`
Custom settervar x: T set(v) { field = v }`field` is the backing storage
Data classdata class P(val x: Int)Generates equals/hashCode/toString/copy/componentN
Copy with changeobj.copy(field = newVal)Shallow copy, unspecified fields unchanged
Destructuringval (a, b) = pairNeeds componentN() functions
Sealed classsealed class RAll subtypes known at compile time, same module
Enum with dataenum class E(val x: Int) { A(1) }Each constant is a singleton instance
Modern enum iterationE.entriesCached List; prefer over values()
Singletonobject Foo { ... }Lazily created, thread-safe, exactly one instance
Static-like memberscompanion object { ... }Scoped to the enclosing class
Interface delegationclass C(x: I) : I by xCompiler forwards every member to x
Module-wide visibilityinternalVisible in the module, not to external consumers
Nested class (default)class Outer { class Nested }No implicit outer instance reference
Inner classclass Outer { inner class Inner }Holds a reference to its creating Outer instance
Qualified thisthis@OuterDisambiguates outer vs inner receiver