Skip to content

Kotlin Classes — Object-Oriented Programming Guide

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Kotlin Classes. We cover key concepts, practical examples, and best practices to help you master this topic.

Kotlin classes use a concise declaration syntax with primary and secondary constructors, init initialization blocks, computed properties with custom getters and setters, and companion objects that replace Java's static members.

What You'll Learn

  • Declare classes with primary and secondary constructors
  • Define properties with default values and custom accessors
  • Use init blocks for validation and initialization
  • Control visibility with private, protected, internal, and public
  • Create companion objects for static-like members
  • Write nested and inner classes
  • Override equals, hashCode, and toString

Why It Matters

Classes are the foundation of object-oriented programming in Kotlin. The language's class syntax reduces boilerplate by 50% compared to Java. Primary constructors, default values, and property declarations combine into a single line. Init blocks ensure objects are valid after construction. Companion objects provide a clean replacement for static methods. Mastering these features lets you model real-world entities correctly and maintainably.

Real-World Use

DodaTech's data models use Kotlin classes with primary constructors and validation in init blocks. The configuration system uses companion objects for factory methods. Nested classes organize UI component definitions within host classes, keeping related code together.

Learning Path

flowchart LR
  A[Lambdas] --> B[Classes\nYou are here]
  B --> C[Inheritance]
  style B fill:#f90,color:#fff

Class Declaration

Kotlin classes are declared with the class keyword. The primary constructor is part of the class header.

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

fun main() {
    val person = Person("Alice", 25)
    println(person.name)  // Output: Alice
    println(person.age)   // Output: 25
    
    person.age = 26
    println(person.age)   // Output: 26
}

Output: The class declaration defines both the constructor and the properties in one line. Properties declared with val are read-only, var are mutable.

Primary Constructor and Init Blocks

The primary constructor cannot contain code. Use init blocks for validation and initialization.

class User(
    val email: String,
    val username: String,
    var isActive: Boolean = true
) {
    // Init block runs during object creation
    init {
        require(email.contains("@")) {
            "Invalid email: $email must contain @"
        }
        require(username.length >= 3) {
            "Username must be at least 3 characters"
        }
        println("User created: $username")
    }
    
    init {
        // Multiple init blocks run in order
        if (!isActive) {
            println("Warning: Inactive user created")
        }
    }
}

fun main() {
    val user = User("alice@example.com", "alice")
    println("Active: ${user.isActive}")  // Output: Active: true
    
    // val invalid = User("bad", "ab")  // Throws IllegalArgumentException
}

Output: Init blocks run during construction, validating parameters before the object is fully initialized.

Secondary Constructors

Secondary constructors use the constructor keyword and must delegate to the primary constructor.

class Rectangle {
    var width: Double
    var height: Double
    
    // Primary constructor
    constructor(width: Double, height: Double) {
        this.width = width
        this.height = height
    }
    
    // Secondary constructor (square)
    constructor(side: Double) : this(side, side)
    
    // Secondary constructor with default
    constructor() : this(1.0, 1.0) {
        println("Default rectangle created")
    }
    
    fun area() = width * height
}

fun main() {
    val rect1 = Rectangle(5.0, 3.0)
    println(rect1.area())  // Output: 15.0
    
    val square = Rectangle(4.0)
    println(square.area())  // Output: 16.0
    
    val default = Rectangle()
    println(default.area())  // Output: 1.0
}

Output: Secondary constructors provide alternative ways to create objects. They must delegate to another constructor using this().

In most cases, use default parameter values instead of secondary constructors for cleaner code.

Properties with Custom Accessors

Properties in Kotlin can have custom getters and setters.

class Temperature {
    var celsius: Double = 0.0
    
    // Custom getter for computed property
    val fahrenheit: Double
        get() = celsius * 9.0 / 5.0 + 32.0
    
    // Custom setter validates values
    var kelvin: Double
        get() = celsius + 273.15
        set(value) {
            require(value >= 0.0) { "Kelvin cannot be negative" }
            celsius = value - 273.15
        }
    
    // Property with custom setter only
    var name: String = ""
        set(value) {
            println("Setting name to: $value")
            field = value  // Backing field
        }
    
    // Private setter
    var id: String = ""
        private set
}

fun main() {
    val temp = Temperature()
    temp.celsius = 25.0
    
    println("C: ${temp.celsius}")        // Output: C: 25.0
    println("F: ${temp.fahrenheit}")     // Output: F: 77.0
    
    temp.kelvin = 300.0
    println("C: ${temp.celsius}")        // Output: C: 26.85
    
    temp.name = "Room Temperature"       // Output: Setting name to: Room Temperature
    // temp.id = "new"                   // Error: setter is private
}

Output: Custom accessors compute values on access and validate on assignment. The backing field is accessed with the field keyword.

Visibility Modifiers

Kotlin has four visibility modifiers.

  • private: visible inside the class only
  • protected: visible in the class and subclasses
  • internal: visible in the same module
  • public: visible everywhere (default)
class BankAccount(
    private val accountNumber: String,
    internal var holderName: String,
    private var balance: Double
) {
    protected val accountType: String = "Checking"
    
    fun deposit(amount: Double) {
        require(amount > 0) { "Amount must be positive" }
        balance += amount
        println("Deposited $amount. New balance: $balance")
    }
    
    fun withdraw(amount: Double): Boolean {
        if (amount > balance) return false
        balance -= amount
        println("Withdrew $amount. New balance: $balance")
        return true
    }
    
    fun getBalance(): Double = balance
}

fun main() {
    val account = BankAccount("12345", "Alice", 1000.0)
    account.holderName = "Alice Smith"  // OK: internal
    // account.balance = 2000.0          // Error: private
    // account.accountType               // Error: protected
    
    account.deposit(500.0)   // Output: Deposited 500.0. New balance: 1500.0
    account.withdraw(200.0)  // Output: Withdrew 200.0. New balance: 1300.0
}

Output: Visibility modifiers enforce Encapsulation at compile time.

Companion Objects

Companion objects replace Java's static members. They are Singleton objects tied to the class.

class ConfigManager {
    companion object {
        private const val DEFAULT_TIMEOUT = 30
        private const val DEFAULT_RETRIES = 3
        
        fun createDefault(): ConfigManager = ConfigManager()
        
        val version: String = "1.0.0"
        
        fun loadFromFile(path: String): ConfigManager {
            println("Loading config from $path")
            return ConfigManager()
        }
        
        // Nested constants
        const val MAX_USERS = 1000
    }
    
    var timeout: Int = DEFAULT_TIMEOUT
    var retries: Int = DEFAULT_RETRIES
}

fun main() {
    val config = ConfigManager.createDefault()
    println(config.timeout)  // Output: 30
    
    println(ConfigManager.version)   // Output: 1.0.0
    println(ConfigManager.MAX_USERS) // Output: 1000
    
    val loaded = ConfigManager.loadFromFile("/etc/app/config.json")
    println(loaded.retries)  // Output: 3
}

Output: Companion object members are accessed through the class name, like static members in Java.

Nested and Inner Classes

Nested classes are static by default. Inner classes hold a reference to the outer class.

class OuterClass {
    private val outerData = "Outer data"
    
    // Nested class (static, no reference to outer)
    class Nested {
        fun show() = "Nested class"
    }
    
    // Inner class (holds reference to outer)
    inner class Inner {
        fun show() = "Inner accessing: $outerData"
        fun getOuter() = this@OuterClass
    }
}

fun main() {
    val nested = OuterClass.Nested()
    println(nested.show())  // Output: Nested class
    
    val outer = OuterClass()
    val inner = outer.Inner()
    println(inner.show())  // Output: Inner accessing: Outer data
}

Output: Nested classes do not access outer class members. Inner classes can access the outer class through the this@OuterClass syntax.

Data Classes (Brief)

Data classes are classes whose main purpose is to hold data.

data class Product(val id: Int, val name: String, val price: Double)

fun main() {
    val p1 = Product(1, "Laptop", 999.99)
    val p2 = Product(1, "Laptop", 999.99)
    
    println(p1)                  // Output: Product(id=1, name=Laptop, price=999.99)
    println(p1 == p2)            // Output: true (structural equality)
    println(p1 === p2)           // Output: false (different objects)
    
    val p3 = p1.copy(price = 899.99)
    println(p3)                  // Output: Product(id=1, name=Laptop, price=899.99)
    
    val (id, name, price) = p1  // Destructuring
    println("$id: $name at \$$price")
}

Output: Data classes automatically provide toString, equals, hashCode, copy, and componentN functions.

Common Mistakes

  1. Using var when val suffices for class properties: If a property does not change after construction, declare it with val. This makes the class easier to reason about.

  2. Forgetting init blocks run in order: Multiple init blocks execute in the order they appear in the class body. Do not rely on init block order for complex logic.

  3. Calling virtual functions in constructors or init blocks: When a virtual function is called during construction, the subclass implementation may not be fully initialized yet. Avoid this pattern.

  4. Not using require and check for validation: Init blocks should validate parameters with require (for arguments) and check (for state). This fails fast with clear error messages.

  5. Confusing nested and inner classes: Nested classes are static and do not hold an outer class reference. Inner classes are non-static and hold a reference. Use nested by default unless you need access to the outer instance.

  6. Leaving properties public without encapsulation: Default visibility is public. Make properties private and expose them through functions or custom getters when you need control.

Practice Questions

  1. What is the difference between a primary constructor and a secondary constructor?

Answer: The primary constructor is part of the class header and cannot contain code. Secondary constructors use the constructor keyword, can contain code, and must delegate to the primary constructor.

  1. What does an init block do?

Answer: An init block runs during object initialization, after the primary constructor. It is used for validation, logging, and initialization logic that the primary constructor cannot contain.

  1. How do you create a property that is publicly readable but privately settable?

Answer: Declare the property with a private setter: var name: String = "" private set.

  1. What is the purpose of a companion object?

Answer: A companion object provides a place for static-like members that belong to the class rather than instances. It replaces Java's static keyword.

  1. Challenge: Create a Bank class that maintains a list of BankAccount objects. Each BankAccount has an account number, holder name, and balance. Implement deposit, withdraw, transfer, and a companion object Factory Method that generates sequential account numbers.

Answer:

class BankAccount private constructor(
    val accountNumber: String,
    var holderName: String,
    var balance: Double
) {
    companion object {
        private var nextNumber = 1000
        
        fun create(holder: String, initialDeposit: Double): BankAccount {
            val number = "ACC-${nextNumber++}"
            return BankAccount(number, holder, initialDeposit)
        }
    }
    
    fun deposit(amount: Double) {
        if (amount > 0) balance += amount
    }
    
    fun withdraw(amount: Double): Boolean {
        return if (amount <= balance) {
            balance -= amount
            true
        } else false
    }
}

class Bank {
    private val accounts = mutableListOf<BankAccount>()
    
    fun openAccount(holder: String, deposit: Double): BankAccount {
        val account = BankAccount.create(holder, deposit)
        accounts.add(account)
        return account
    }
    
    fun transfer(from: String, to: String, amount: Double): Boolean {
        val fromAcc = accounts.find { it.accountNumber == from } ?: return false
        val toAcc = accounts.find { it.accountNumber == to } ?: return false
        return if (fromAcc.withdraw(amount)) {
            toAcc.deposit(amount)
            true
        } else false
    }
}

Mini Project

Build an inventory management system with classes. Requirements:

  • Item class with id, name, quantity, and price
  • Inventory class that manages a collection of Items
  • Stock validation in init blocks (quantity cannot be negative)
  • A restock() method in the companion object
  • Private setters for id and price after creation
  • Nested class for inventory statistics
  • Use require() for parameter validation

This project reinforces all class concepts in a practical business application.

FAQ

Can a Kotlin class have multiple constructors?

Yes. Use secondary constructors with the constructor keyword. All secondary constructors must delegate to the primary constructor using this().

What is the difference between field and property in Kotlin?

A field is a backing storage for a property. A property is the combination of a field, a getter, and optionally a setter. Kotlin encourages properties over direct field access.

Can I create a class without a constructor?

Yes. A class with no explicit constructor has a default no-argument constructor. Properties must be initialized at declaration, in init blocks, or guaranteed to be initialized before use.

What is internal visibility?

Internal means the member is visible within the same module only. A module is a set of Kotlin files compiled together, such as a Gradle module.

How do I make a class non-instantiable?

Use a private constructor. This is useful for utility classes with only companion object members.

What's Next

After mastering classes, learn inheritance to extend classes and create hierarchies. You can also explore interfaces for defining contracts.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro