Classes #

A class is a blueprint for creating objects — it defines what data is stored (properties) and what can be done (methods). In Kotlin, classes are designed to be more concise than in Java: constructors can go directly in the class header, properties can be declared there at the same time, and much of the boilerplate like equals(), hashCode(), and toString() can be generated automatically with data class. This article covers all class forms available in Kotlin in depth — from ordinary classes, data classes, sealed classes, objects, to important patterns like inheritance and visibility.

Defining a Class #

The simplest class in Kotlin needs only one keyword:

class EmptyClass  // no curly braces needed if there's no body

A more useful class has properties and methods:

class Car(val brand: String, val model: String, var year: Int) {

    // Additional properties with default values
    var odometer: Double = 0.0
    var color: String = "White"

    // Methods
    fun drive(distanceKm: Double) {
        odometer += distanceKm
        println("$brand $model drove $distanceKm km. Total: $odometer km")
    }

    fun info(): String = "$brand $model ($year) — odometer: ${odometer}km"

    override fun toString() = info()
}

val car = Car("Toyota", "Avanza", 2022)
car.color = "Red"
car.drive(150.0)
car.drive(75.5)
println(car)  // Toyota Avanza (2022) — odometer: 225.5km

Notice: in Kotlin there’s no new keyword to create objects — just call the class name like a function.


Constructors #

Primary Constructor #

The primary constructor is written directly in the class header, after the class name. Parameters marked with val or var automatically become class properties.

class User(
    val id: Long,
    val name: String,
    val email: String,
    var active: Boolean = true
)

val user = User(1L, "Budi Santoso", "[email protected]")
println(user.name)   // Budi Santoso
println(user.active)  // true

Parameters without val/var are only available during initialization — they don’t become properties:

class Circle(radius: Double) {
    // radius is only available here, not as a property
    val area = Math.PI * radius * radius
    val circumference = 2 * Math.PI * radius
}

val circle = Circle(5.0)
println(circle.area)      // 78.53...
// circle.radius          // ✗ error — not a property

init Blocks #

An init block executes right after the primary constructor. There can be more than one init block — they all execute in sequence.

class Account(val username: String, val email: String) {

    val normalizedUsername: String

    init {
        // Validation at construction time
        require(username.length >= 3) { "Username must be at least 3 characters" }
        require(email.contains("@")) { "Invalid email format" }

        normalizedUsername = username.lowercase().trim()
        println("Account '$normalizedUsername' created successfully")
    }
}

val account = Account("Budi", "[email protected]")  // "Account 'budi' created successfully"
// val invalid = Account("ab", "not-an-email") // ✗ IllegalArgumentException

Secondary Constructors #

Secondary constructors are declared with the constructor keyword inside the class body. They must delegate to the primary constructor (directly or indirectly) using this(...).

class Point(val x: Double, val y: Double) {

    // Secondary constructor: create a Point from integer coordinates
    constructor(x: Int, y: Int) : this(x.toDouble(), y.toDouble())

    // Secondary constructor: create a Point at the origin
    constructor() : this(0.0, 0.0)

    fun distanceTo(other: Point): Double {
        val dx = x - other.x
        val dy = y - other.y
        return Math.sqrt(dx * dx + dy * dy)
    }

    override fun toString() = "($x, $y)"
}

val p1 = Point(3.0, 4.0)
val p2 = Point(0, 0)         // secondary constructor
val p3 = Point()             // secondary constructor
println(p1.distanceTo(p2))   // 5.0
In everyday practice, secondary constructors are rarely needed in Kotlin because default parameters already handle most cases. Use secondary constructors only if you need fundamentally different logic between construction methods.

Properties and Custom Getters/Setters #

Properties in Kotlin are more than just fields — they can have custom getters and setters that execute every time the property is accessed or modified.

class Temperature(private var _celsius: Double) {

    // Property with a custom getter and setter
    var celsius: Double
        get() = _celsius
        set(value) {
            require(value >= -273.15) { "Temperature must not be below absolute zero" }
            _celsius = value
        }

    // Computed property — calculated from other properties
    val fahrenheit: Double
        get() = celsius * 9.0 / 5.0 + 32

    val kelvin: Double
        get() = celsius + 273.15

    val temperatureStatus: String
        get() = when {
            celsius < 0   -> "Freezing"
            celsius < 20  -> "Cold"
            celsius < 30  -> "Comfortable"
            celsius < 37  -> "Warm"
            else          -> "Hot"
        }
}

val temperature = Temperature(25.0)
println(temperature.celsius)      // 25.0
println(temperature.fahrenheit)   // 77.0
println(temperature.kelvin)       // 298.15
println(temperature.temperatureStatus)   // Comfortable

temperature.celsius = 100.0
println(temperature.fahrenheit)   // 212.0
// temperature.celsius = -300.0   // ✗ IllegalArgumentException

field — The Backing Field #

Inside a getter or setter, use field (not the property name) to access the actually stored value. This avoids infinite recursion:

class Score {
    var value: Int = 0
        set(value) {
            field = if (value < 0) 0 else value  // use 'field', not 'value'
        }
}

val score = Score()
score.value = 100
println(score.value)  // 100
score.value = -50
println(score.value)  // 0 — corrected to the minimum

Visibility #

Kotlin has four visibility modifiers:

ModifierAccessible from
public (default)Anywhere
privateOnly within the same class
protectedWithin the same class and subclasses
internalWithin the same module
class BankAccount(private val accountNumber: String) {

    private var _balance: Double = 0.0

    val balance: Double       // read-only from outside
        get() = _balance

    internal fun internalAudit(): String = "Account $accountNumber: $_balance"

    fun deposit(amount: Double) {
        require(amount > 0) { "Deposit amount must be positive" }
        _balance += amount
        recordTransaction("DEPOSIT", amount)  // private — only callable from inside
    }

    fun withdraw(amount: Double) {
        require(amount > 0) { "Withdrawal amount must be positive" }
        require(_balance >= amount) { "Insufficient balance" }
        _balance -= amount
        recordTransaction("WITHDRAW", amount)
    }

    private fun recordTransaction(type: String, amount: Double) {
        println("[LOG] $type Rp${\"%,.0f\".format(amount)} | Balance: Rp${\"%,.0f\".format(_balance)}")
    }
}

val account = BankAccount("1234567890")
account.deposit(1_000_000.0)
account.withdraw(250_000.0)
println("Balance: Rp${\"%,.0f\".format(account.balance)}")
// account._balance   // ✗ error — private
// account.accountNumber  // ✗ error — private

Inheritance #

In Kotlin, all classes are final by default — they can’t be inherited from. To allow inheritance, mark the class with open. The same applies to methods you want to be overridable.

open class Animal(val name: String) {

    open fun makeSound(): String = "..."

    open fun describe(): String = "$name sounds like: ${makeSound()}"

    // Final method — can't be overridden
    fun sleep() = println("$name is sleeping...")
}

class Cat(name: String) : Animal(name) {
    override fun makeSound() = "Meow!"
}

class Dog(name: String, val breed: String) : Animal(name) {
    override fun makeSound() = "Woof!"

    override fun describe() = "${super.describe()} (Breed: $breed)"
}

class Parrot(name: String, private val phrases: String) : Animal(name) {
    override fun makeSound() = phrases
}

val animals = listOf(
    Cat("Mimi"),
    Dog("Rex", "German Shepherd"),
    Parrot("Polly", "Hello! Who are you?")
)

animals.forEach { println(it.describe()) }
// Mimi sounds like: Meow!
// Rex sounds like: Woof! (Breed: German Shepherd)
// Polly sounds like: Hello! Who are you?

Preventing Further Overrides #

Use final on an override to stop the override chain:

open class Shape {
    open fun draw() = println("Drawing a shape")
}

open class Triangle : Shape() {
    final override fun draw() = println("Drawing a triangle")
    // Classes derived from Triangle can't override draw() anymore
}

Abstract Classes #

Abstract classes can’t be instantiated directly. They define a contract of abstract methods that subclasses must implement.

abstract class Report(val title: String) {

    // Abstract methods — must be implemented by subclasses
    abstract fun buildContent(): String
    abstract fun format(): String

    // Concrete method — already has an implementation, can be overridden
    open fun header(): String = "=== $title ===\n"

    // Final method — can't be overridden
    fun print() {
        println(header())
        println(buildContent())
        println("\nFormat: ${format()}")
    }
}

class SalesReport(title: String, private val totalSales: Double) : Report(title) {
    override fun buildContent() = "Total Sales: Rp${\"%,.0f\".format(totalSales)}"
    override fun format() = "PDF"
}

class StockReport(title: String, private val productList: Map<String, Int>) : Report(title) {
    override fun buildContent(): String {
        return productList.entries.joinToString("\n") { (product, stock) ->
            "  - $product: $stock units"
        }
    }
    override fun format() = "Excel"
    override fun header() = "📊 ${super.header()}"
}

val report = SalesReport("Q1 2024", 125_000_000.0)
report.print()

val stock = StockReport("March Inventory", mapOf("Laptop" to 15, "Mouse" to 42, "Keyboard" to 28))
stock.print()

Data Classes #

A data class is a class whose job is to hold data. Kotlin automatically generates toString(), equals(), hashCode(), and copy() based on the properties in the primary constructor.

data class Product(
    val id: Long,
    val name: String,
    val price: Double,
    val category: String,
    val stock: Int = 0
)

val laptop = Product(1L, "Gaming Laptop", 15_000_000.0, "Electronics", 10)
val mouse  = Product(2L, "Wireless Mouse", 250_000.0, "Electronics", 50)

// Automatic toString()
println(laptop)
// Product(id=1, name=Gaming Laptop, price=1.5E7, category=Electronics, stock=10)

// equals() compares values, not references
val laptop2 = Product(1L, "Gaming Laptop", 15_000_000.0, "Electronics", 10)
println(laptop == laptop2)   // true

// copy() creates a copy with some different values
val discountedLaptop = laptop.copy(price = 13_500_000.0)
println(discountedLaptop.price)  // 1.35E7

// Destructuring
val (id, name, price) = laptop
println("$id: $name — Rp${\"%,.0f\".format(price)}")

Data Class vs Regular Class #

USE a data class if:
  ✓ The class serves as a data container (DTO, model, API response)
  ✓ You need equals() and hashCode() based on values
  ✓ You need copy() to create slightly different versions
  ✓ You need destructuring

USE a regular class if:
  ✓ The class has significant business logic
  ✓ You need to control equals()/hashCode() custom behavior
  ✓ The class is designed to be inherited (data classes can't be open)
  ✓ Object identity matters more than its value

Object — Singleton and Anonymous Object #

Singleton with object #

object declares a singleton — a class that has only one instance, created on first access.

object AppConfig {
    const val VERSION = "2.1.0"
    const val APP_NAME = "MyKotlinApp"
    var devMode = false

    fun info() = "$APP_NAME v$VERSION (dev=$devMode)"

    fun loadFromEnv() {
        devMode = System.getenv("APP_ENV") == "development"
    }
}

println(AppConfig.info())
AppConfig.loadFromEnv()

Anonymous Objects #

Anonymous objects are useful for creating one-off implementations of an interface or abstract class without defining a named class:

interface ClickListener {
    fun onClick(source: String)
}

fun attachClick(listener: ClickListener) {
    listener.onClick("OK Button")
}

// Inline implementation without a class name
attachClick(object : ClickListener {
    override fun onClick(source: String) {
        println("Clicked: $source")
    }
})

Sealed Classes #

A sealed class is a closed class hierarchy — all its subclasses must be declared in the same file. This lets the compiler know all possible subtypes, so when can perform exhaustive checks.

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Failure(val message: String, val code: Int = 0) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
    object NoConnection : NetworkResult<Nothing>()
}

fun <T> handleResult(result: NetworkResult<T>): String {
    return when (result) {
        is NetworkResult.Success      -> "Data received: ${result.data}"
        is NetworkResult.Failure      -> "Error ${result.code}: ${result.message}"
        is NetworkResult.Loading      -> "Loading..."
        is NetworkResult.NoConnection -> "Check your internet connection"
        // No else needed — the compiler knows all cases are handled
    }
}

println(handleResult(NetworkResult.Success("API response")))
println(handleResult(NetworkResult.Failure("Server unavailable", 503)))
println(handleResult(NetworkResult.Loading))

Companion Objects #

A companion object is a singleton tied to a class — equivalent to static in Java. It can have a name, or use the default name Companion.

class Token private constructor(val value: String, val expiry: Long) {

    companion object {
        private const val VALIDITY_MS = 3_600_000L  // 1 hour

        fun create(userId: Long): Token {
            val value = "tok_${userId}_${System.currentTimeMillis()}"
            val expiry = System.currentTimeMillis() + VALIDITY_MS
            return Token(value, expiry)
        }

        fun fromString(raw: String): Token? {
            val parts = raw.split("_")
            return if (parts.size == 3) {
                Token(raw, System.currentTimeMillis() + VALIDITY_MS)
            } else null
        }
    }

    val isValid: Boolean
        get() = System.currentTimeMillis() < expiry

    override fun toString() = "Token($value, valid=$isValid)"
}

val token = Token.create(42L)
println(token)
println(token.isValid)
// val invalid = Token("abc", 0L)  // ✗ error — constructor is private

The Class Hierarchy in Kotlin #

flowchart TD
    A["regular class\n(final by default)"] --> B["open class\n(inheritable)"]
    B --> C["abstract class\n(can't be instantiated)"]
    D["data class\n(automatic equals/hashCode/copy)"] --> E["Can't be open\ncan't be abstract"]
    F["sealed class\n(limited subtypes in the same file)"] --> G["Subtypes can be class,\ndata class, or object"]
    H["object\n(singleton)"] --> I["companion object\n(tied to a class)"]
    H --> J["anonymous object\n(one-off implementation)"]

Summary #

  • Primary constructor in the header — parameters marked val/var become properties directly. Parameters without those markers are only available during initialization in init.
  • init blocks for validation — use require() or check() in init to ensure objects are always in a valid state when created.
  • All classes are final by default — add open to allow inheritance, and open on every method that may be overridden.
  • Custom getters/setters with field — use field inside getters/setters to refer to the property’s actual stored value, not the property name itself (to avoid recursion).
  • data class for data models — get equals(), hashCode(), toString(), and copy() for free. Use it for DTOs, API responses, and domain models.
  • object for singletons — Kotlin guarantees one instance per JVM session. Safer and more concise than the manual Singleton pattern.
  • sealed class for closed types — all subtypes are known to the compiler, so when can be exhaustive. Ideal for operation results (Success/Failure/Loading) and state machines.
  • companion object replaces static — companion object members can be called with the class name, can have their own name, and can implement interfaces.
  • Use private aggressively — hide implementation details with private. Expose only what actually needs to be accessed from outside.

← Previous: Functions   Next: Interfaces →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact