Constructors
primary ยท secondary ยท initEvery 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.
// 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 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
// 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)
class Point(val x: Int = 0, val y: Int = 0) replaces the whole example above.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
get() ยท set() ยท backing fieldKotlin 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.
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
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.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
data class ยท equals ยท copy ยท destructuringA 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.
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
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
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
sealed ยท exhaustive when ยท sum typesA 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.
// 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 }
// 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) }
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.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
enum class ยท entries ยท behavior per constantAn 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 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}") }
// 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
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
object ยท companion object ยท no staticKotlin 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.
// 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") }
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
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.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
by ยท composition over inheritanceImplementing 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.
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" }
LoudPlayer example.Visibility Modifiers
public ยท private ยท protected ยท internalKotlin 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.
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 }
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
nested (default) ยท inner ยท outer referenceA 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.
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())
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"
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.inner is closer to a closure that captures a receiver.Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Primary constructor property | class User(val name: String) | val/var on a param generates a property |
| init block | init { ... } | Runs in declaration order with property initializers |
| Secondary constructor | constructor(x: T) : this(...) | Must delegate to the primary constructor |
| Custom getter | val x: T get() = ... | No backing field unless referenced via `field` |
| Custom setter | var x: T set(v) { field = v } | `field` is the backing storage |
| Data class | data class P(val x: Int) | Generates equals/hashCode/toString/copy/componentN |
| Copy with change | obj.copy(field = newVal) | Shallow copy, unspecified fields unchanged |
| Destructuring | val (a, b) = pair | Needs componentN() functions |
| Sealed class | sealed class R | All subtypes known at compile time, same module |
| Enum with data | enum class E(val x: Int) { A(1) } | Each constant is a singleton instance |
| Modern enum iteration | E.entries | Cached List; prefer over values() |
| Singleton | object Foo { ... } | Lazily created, thread-safe, exactly one instance |
| Static-like members | companion object { ... } | Scoped to the enclosing class |
| Interface delegation | class C(x: I) : I by x | Compiler forwards every member to x |
| Module-wide visibility | internal | Visible in the module, not to external consumers |
| Nested class (default) | class Outer { class Nested } | No implicit outer instance reference |
| Inner class | class Outer { inner class Inner } | Holds a reference to its creating Outer instance |
| Qualified this | this@Outer | Disambiguates outer vs inner receiver |