In the previous tutorial, you learned about control flow. Now let’s learn about classes — the foundation of object-oriented programming in Kotlin.

Kotlin classes are more concise than Java classes. What takes 50 lines in Java takes 1 line in Kotlin with data classes.

In this tutorial, you will learn:

  • Basic classes and properties
  • Constructors (primary and secondary)
  • Inheritance
  • Abstract classes and interfaces
  • Data classes
  • Enum classes
  • Sealed classes
  • Objects and companion objects

Basic Class

A class in Kotlin has a primary constructor in the class header:

class Person(val name: String, var age: Int) {
    fun introduce(): String {
        return "Hi, I'm $name and I'm $age years old."
    }

    fun haveBirthday() {
        age++
    }
}

Usage:

val person = Person("Alex", 25)
println(person.introduce()) // Hi, I'm Alex and I'm 25 years old.
println(person.name)        // Alex

person.haveBirthday()
println(person.age) // 26

Key points:

  • val name — read-only property (getter only)
  • var age — read-write property (getter and setter)
  • No new keyword needed to create an instance

Properties

Custom Getters and Setters

class BankAccount(val owner: String, initialBalance: Double) {
    var balance: Double = initialBalance
        private set // Can only be changed inside this class

    // Computed property — calculated every time you access it
    val isOverdrawn: Boolean
        get() = balance < 0

    fun deposit(amount: Double) {
        require(amount > 0) { "Deposit amount must be positive" }
        balance += amount
    }

    fun withdraw(amount: Double): Boolean {
        require(amount > 0) { "Withdrawal amount must be positive" }
        return if (balance >= amount) {
            balance -= amount
            true
        } else {
            false
        }
    }
}

Usage:

val account = BankAccount("Sam", 1000.0)
account.deposit(500.0)
println(account.balance)    // 1500.0

account.withdraw(200.0)
println(account.balance)    // 1300.0
println(account.isOverdrawn) // false

// account.balance = 9999.0  // Error: setter is private

private set means only the class itself can change the value. Outside code can read it but not write it.

Init Block

The init block runs when the object is created:

class BankAccount(val owner: String, initialBalance: Double) {
    init {
        require(owner.isNotBlank()) { "Owner name cannot be blank" }
    }
    // ...
}

// This crashes with IllegalArgumentException
// val bad = BankAccount("", 100.0)

Constructors

Primary Constructor

The primary constructor is part of the class header:

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

Secondary Constructor

Secondary constructors provide alternative ways to create objects:

class Rectangle(val width: Double, val height: Double) {
    // Secondary constructor — creates a square
    constructor(side: Double) : this(side, side)

    val area: Double get() = width * height
    val isSquare: Boolean get() = width == height
}

Usage:

val rect = Rectangle(10.0, 5.0)
println(rect.area)     // 50.0
println(rect.isSquare) // false

val square = Rectangle(7.0)  // Uses secondary constructor
println(square.area)     // 49.0
println(square.isSquare) // true

Secondary constructors must call the primary constructor with this(...).

Inheritance

In Kotlin, classes are final by default. You must use open to allow inheritance:

open class Animal(val name: String, val sound: String) {
    open fun makeSound(): String {
        return "$name says $sound"
    }

    fun describe(): String {
        return "I am a $name"
    }
}

class Dog(name: String) : Animal(name, "Woof") {
    override fun makeSound(): String {
        return "$name says Woof! Woof!"
    }

    fun fetch(item: String): String {
        return "$name fetches the $item"
    }
}

class Cat(name: String) : Animal(name, "Meow") {
    override fun makeSound(): String {
        return "$name says Meow~"
    }
}

Usage:

val dog = Dog("Rex")
println(dog.makeSound()) // Rex says Woof! Woof!
println(dog.fetch("ball")) // Rex fetches the ball
println(dog.describe())    // I am a Rex

val cat = Cat("Luna")
println(cat.makeSound()) // Luna says Meow~

Rules:

  • open class — can be inherited
  • open fun — can be overridden
  • override fun — overrides a parent function
  • The : Animal(name, "Woof") syntax calls the parent constructor

Abstract Classes

Abstract classes cannot be instantiated. They define a contract that subclasses must follow:

abstract class Shape {
    abstract fun area(): Double
    abstract fun perimeter(): Double

    // Non-abstract function — shared by all subclasses
    fun description(): String {
        return "${this::class.simpleName}: area=${"%.2f".format(area())}"
    }
}

class Circle(val radius: Double) : Shape() {
    override fun area() = Math.PI * radius * radius
    override fun perimeter() = 2 * Math.PI * radius
}

class Square(val side: Double) : Shape() {
    override fun area() = side * side
    override fun perimeter() = 4 * side
}

Usage:

val circle = Circle(5.0)
println(circle.description()) // Circle: area=78.54

val square = Square(4.0)
println(square.description()) // Square: area=16.00

Interfaces

Interfaces define a contract. A class can implement multiple interfaces:

interface Printable {
    fun print(): String
}

interface Loggable {
    // Default implementation
    fun log(): String {
        return "[LOG] ${this::class.simpleName}"
    }
}

class Report(val title: String, val content: String) : Printable, Loggable {
    override fun print(): String {
        return "=== $title ===\n$content"
    }
    // Uses default log() from Loggable
}

Usage:

val report = Report("Monthly Report", "Everything is going well.")
println(report.print())  // === Monthly Report === ...
println(report.log())    // [LOG] Report

Interfaces can have default implementations (like log() above). Classes can override them or use the defaults.

Data Classes

Data classes are Kotlin’s killer feature for holding data. One line replaces dozens of lines of Java:

data class User(
    val name: String,
    val age: Int,
    val email: String
)

This single line generates:

  • equals() — compares by values
  • hashCode() — consistent with equals
  • toString() — readable output like User(name=Alex, age=25, email=alex@example.com)
  • copy() — create a modified copy
  • componentN() — for destructuring

Using Data Classes

val user1 = User("Alex", 25, "alex@example.com")
val user2 = User("Alex", 25, "alex@example.com")

// equals — compares by values
println(user1 == user2) // true

// toString — readable
println(user1) // User(name=Alex, age=25, email=alex@example.com)

// copy — change some fields
val user3 = user1.copy(name = "Sam", age = 30)
println(user3) // User(name=Sam, age=30, email=alex@example.com)

// Destructuring
val (name, age, email) = user1
println("$name, $age, $email") // Alex, 25, alex@example.com

copy() is especially useful when working with immutable data. Instead of changing an object, you create a new one with the changes you want.

Enum Classes

Enums represent a fixed set of values:

enum class Color(val hex: String) {
    RED("#FF0000"),
    GREEN("#00FF00"),
    BLUE("#0000FF"),
    WHITE("#FFFFFF"),
    BLACK("#000000");

    fun isDark(): Boolean = this == BLACK || this == BLUE
}

enum class Direction {
    NORTH, SOUTH, EAST, WEST;

    fun opposite(): Direction = when (this) {
        NORTH -> SOUTH
        SOUTH -> NORTH
        EAST -> WEST
        WEST -> EAST
    }
}

Usage:

println(Color.RED.hex)           // #FF0000
println(Color.BLACK.isDark())    // true
println(Direction.NORTH.opposite()) // SOUTH

Enums can have properties, methods, and implement interfaces.

Sealed Classes

Sealed classes represent restricted hierarchies. All subclasses must be in the same file:

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String, val code: Int) : Result()
    data object Loading : Result()
}

The key advantage: when with sealed classes doesn’t need an else branch because the compiler knows all possible subclasses:

fun handleResult(result: Result): String {
    return when (result) {
        is Result.Success -> "Success: ${result.data}"
        is Result.Error -> "Error ${result.code}: ${result.message}"
        is Result.Loading -> "Loading..."
        // No else needed!
    }
}

If you add a new subclass to the sealed class, the compiler forces you to handle it in every when expression. This prevents bugs.

Object (Singleton)

object creates a singleton — a class with exactly one instance:

object AppConfig {
    const val APP_NAME = "Kotlin Tutorial"
    const val VERSION = "1.0"
    var isDebug = false

    fun info(): String = "$APP_NAME v$VERSION (debug=$isDebug)"
}

// Usage — no need to create an instance
println(AppConfig.info()) // Kotlin Tutorial v1.0 (debug=false)
AppConfig.isDebug = true
println(AppConfig.info()) // Kotlin Tutorial v1.0 (debug=true)

Companion Object

Companion objects are like static members in Java:

class DatabaseConnection private constructor(val url: String) {
    companion object {
        private var instance: DatabaseConnection? = null

        fun create(url: String): DatabaseConnection {
            if (instance == null) {
                instance = DatabaseConnection(url)
            }
            return instance!!
        }
    }
}

// Usage
val db = DatabaseConnection.create("jdbc:postgresql://localhost/mydb")

The private constructor means you can only create instances through the companion object’s create function.

Practical Example: Task Manager

Here is a complete example showing classes working together:

data class Task(
    val id: Int,
    val title: String,
    val isCompleted: Boolean = false
)

class TaskManager {
    private val tasks = mutableListOf<Task>()
    private var nextId = 1

    fun addTask(title: String): Task {
        val task = Task(id = nextId++, title = title)
        tasks.add(task)
        return task
    }

    fun completeTask(id: Int): Boolean {
        val index = tasks.indexOfFirst { it.id == id }
        if (index == -1) return false
        tasks[index] = tasks[index].copy(isCompleted = true)
        return true
    }

    fun getPendingTasks(): List<Task> = tasks.filter { !it.isCompleted }
    fun getCompletedTasks(): List<Task> = tasks.filter { it.isCompleted }
}

Usage:

val manager = TaskManager()
manager.addTask("Learn Kotlin basics")
manager.addTask("Write unit tests")
manager.addTask("Build a project")

manager.completeTask(1)

println(manager.getPendingTasks())
// [Task(id=2, title=Write unit tests, isCompleted=false),
//  Task(id=3, title=Build a project, isCompleted=false)]

println(manager.getCompletedTasks())
// [Task(id=1, title=Learn Kotlin basics, isCompleted=true)]

Notice how copy(isCompleted = true) creates a new Task instead of modifying the existing one. This is the immutable pattern — safe and predictable.

Summary

FeatureDescription
classBasic class (final by default)
open classAllows inheritance
abstract classCannot be instantiated
data classAuto-generates equals, hashCode, toString, copy
sealed classRestricted hierarchy
enum classFixed set of values
objectSingleton
companion objectStatic-like members
interfaceContract with optional defaults

Source Code

You can find the complete source code for this tutorial on GitHub:

KT-7 Source Code on GitHub

What’s Next?

In the next tutorial, Kotlin Tutorial #8: Collections — List, Set, Map, and Operations, you will learn:

  • List, MutableList, Set, Map
  • Collection operations: filter, map, flatMap
  • Grouping, sorting, and reducing
  • Practical collection patterns

Collections are used everywhere in Kotlin. They are essential for real-world programming.

Want to see Kotlin classes in action? Check out the Jetpack Compose Tutorial where you build real Android UIs with Kotlin.


This is part 7 of the Kotlin Tutorial series. Check out Part 6: Control Flow if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.