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:
| Modifier | Accessible from |
|---|---|
public (default) | Anywhere |
private | Only within the same class |
protected | Within the same class and subclasses |
internal | Within 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/varbecome properties directly. Parameters without those markers are only available during initialization ininit.initblocks for validation — userequire()orcheck()ininitto ensure objects are always in a valid state when created.- All classes are
finalby default — addopento allow inheritance, andopenon every method that may be overridden.- Custom getters/setters with
field— usefieldinside getters/setters to refer to the property’s actual stored value, not the property name itself (to avoid recursion).data classfor data models — getequals(),hashCode(),toString(), andcopy()for free. Use it for DTOs, API responses, and domain models.objectfor singletons — Kotlin guarantees one instance per JVM session. Safer and more concise than the manual Singleton pattern.sealed classfor closed types — all subtypes are known to the compiler, sowhencan be exhaustive. Ideal for operation results (Success/Failure/Loading) and state machines.companion objectreplacesstatic— companion object members can be called with the class name, can have their own name, and can implement interfaces.- Use
privateaggressively — hide implementation details withprivate. Expose only what actually needs to be accessed from outside.