Kotlin Crash Course
Chapter 5

Classes & OOP

Data classes, sealed classes, inheritance, interfaces, and extension functions. Object-oriented Kotlin without the boilerplate.

By the end of this chapter, you will:

  • Declare classes with primary and secondary constructors
  • Use data classes to eliminate boilerplate
  • Understand sealed classes for restricted hierarchies
  • Implement interfaces and use delegation
  • Add functionality to existing classes with extensions

Declaring Classes

Kotlin classes are concise. The primary constructor is part of the class header.

// Primary constructor with properties
class User(val name: String, var age: Int)

// Properties are automatically created from constructor params
val user = User("Alex", 25)
println(user.name)   // "Alex"
user.age = 26        // ✅ OK — var
// user.name = "Sam" // ❌ ERROR — val

// With default values
class User(val name: String, var age: Int = 0)
val defaultUser = User("Alex")   // age defaults to 0

Init Block

For validation or setup logic, use an init block.

class User(val name: String, var age: Int) {
    init {
        require(age >= 0) { "Age must be non-negative" }
        println("User $name created")
    }
}

Secondary Constructors

class Person(val name: String) {
    var age: Int = 0

    // Secondary constructor must delegate to primary
    constructor(name: String, age: Int) : this(name) {
        this.age = age
    }
}

val p1 = Person("Alex")
val p2 = Person("Alex", 25)

Compare with JS / Python

JavaScript
class User {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}

const user = new User("Alex", 25);
Python
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

user = User("Alex", 25)

Kotlin difference: Constructor params prefixed with val/var automatically become properties. No this.name = name boilerplate.

Practical use: Use regular classes for services and stateful objects. Use primary constructor properties for configuration objects, DTOs, and anything that carries data without behavior.

Data Classes

For classes whose main purpose is to hold data, use data class. The compiler generates equals(), hashCode(), toString(), copy(), and destructuring functions automatically.

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

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

// Auto-generated equals
println(user1 == user2)   // true — compares content, not reference

// Auto-generated toString
println(user1)            // User(name=Alex, email=alex@example.com, age=25)

// Copy with changes (immutable update pattern)
val older = user1.copy(age = 26)
println(older)            // User(name=Alex, email=alex@example.com, age=26)

// Destructuring
val (name, email, age) = user1
println("$name, $email, $age")
Immutable data pattern: Always use val in data classes. When you need to "change" something, use .copy(). This makes your code predictable and thread-safe.
Warning: Properties declared in the class body (not the primary constructor) are excluded from generated equals(), hashCode(), toString(), and copy(). Only primary constructor parameters are used.

Compare with JS / Python

JavaScript
// No built-in data class. You'd use:
class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    // Must implement equals, toString manually
}

// Or just objects:
const user = { name: "Alex", email: "alex@example.com" };
Python
from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str
    email: str
    age: int = 0

user = User("Alex", "alex@example.com")

Kotlin difference: Data classes are built-in and more powerful than Python's @dataclass. The copy() function and destructuring are especially useful.

Enum Classes

Enum classes represent a fixed set of constants. They're ideal for state machines, configuration options, and type-safe categorization.

enum class Priority { LOW, MEDIUM, HIGH }

enum class Status(val code: Int) {
    ACTIVE(1),
    INACTIVE(0)
}

// Every enum has built-in properties
println(Priority.LOW.name)      // "LOW"
println(Priority.LOW.ordinal)   // 0

// Iterating over all values
for (priority in Priority.entries) {
    println(priority)
}

// Finding a value by name
val active = Status.valueOf("ACTIVE")  // Status.ACTIVE
Note: Prefer Priority.entries over the older Priority.values()entries returns an EnumEntries<T> list (more efficient, avoids array copies) and is the modern Kotlin convention since 1.9. Similarly, prefer enumEntries<T>() over the deprecated enumValues<T>().
Use enums for: API status codes, UI themes, sorting modes, payment states, and anywhere you need a closed set of named constants with compile-time safety.

Nested and Inner Classes

Kotlin lets you define classes inside other classes. A nested class is like a static member — it has no reference to the outer instance. An inner class holds a reference to its outer instance.

class Outer {
    val name = "Outer"

    // Nested class — no reference to Outer
    class Nested {
        fun greet() = "Hello from Nested"
    }

    // Inner class — holds reference to Outer
    inner class Inner {
        fun greet() = "Hello from ${this@Outer.name}"
    }
}

val nested = Outer.Nested()   // No Outer instance needed
val outer = Outer()
val inner = outer.Inner()     // Requires Outer instance
Common use cases: Nested classes for self-contained helpers (e.g., ViewHolder in Android adapters). Inner classes for builders that need to access outer properties during construction.

Delegation

The by keyword lets you delegate interface implementation to another object. This is Kotlin's native support for the decorator pattern — no manual forwarding required.

interface UserRepository {
    fun findUser(id: String): User
    fun addUser(user: User)
}

class RealRepository : UserRepository {
    override fun findUser(id: String): User {
        // query database
        return User(id, "Alex")
    }

    override fun addUser(user: User) {
        // insert into database
    }
}

// Delegates all UserRepository calls to `delegate`,
// then overrides only findUser to add caching
class CachedRepository(
    private val delegate: UserRepository
) : UserRepository by delegate {
    private val cache = mutableMapOf()

    override fun findUser(id: String): User {
        return cache.getOrPut(id) { delegate.findUser(id) }
    }
    // addUser() is automatically delegated — no boilerplate!
}

// Delegates to `delegate`, then adds logging to specific methods
class LoggingRepository(
    private val delegate: UserRepository
) : UserRepository by delegate {
    override fun findUser(id: String): User {
        println("Finding user $id")
        return delegate.findUser(id)
    }
    // addUser() is automatically delegated — no boilerplate!
}
Why this is powerful: Without by, you'd have to manually implement every method in the interface just to override one. With delegation, the compiler auto-generates those forwarding calls. You only write the methods you want to customize.
Use delegation for: Caching layers, logging wrappers, metrics collection, and transaction management — anywhere you want to add cross-cutting concerns without inheritance.

Lateinit Properties

Use lateinit when you must initialize a property after construction — common in dependency injection and Android view binding.

class MainActivity {
    lateinit var viewModel: MainViewModel
    lateinit var username: String

    fun onCreate() {
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
        username = "Alex"

        // Check initialization (useful for testing)
        if (::username.isInitialized) {
            println(username)
        }
    }
}
lateinit rules: Must be var (not val), non-nullable, and non-primitive (no Int, Boolean, etc.). Accessing before initialization throws UninitializedPropertyAccessException.

Properties with Custom Getters/Setters

Properties aren't just fields — they can have custom accessors. The field identifier refers to the backing field.

class User {
    var name: String = ""
        get() = field.uppercase()           // transform on read
        set(value) {
            field = value.trim()             // validate/clean on write
        }

    // Computed property with no backing field
    val displayName: String
        get() = "User: $name"
}

val user = User()
user.name = "  alex  "
println(user.name)          // "ALEX"
println(user.displayName)   // "User: ALEX"
Use custom accessors for: Input validation, formatting, lazy computation, derived state, and encapsulating side effects like logging when a property changes.

Sealed Classes

A sealed class is a class with a restricted hierarchy. All subclasses must be defined in the same file. This is perfect for representing restricted states, like API results or UI states.

sealed class Result<out T>
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()   // singleton for stateless case

// The compiler knows all possible subtypes
fun handle(result: Result<String>) = when (result) {
    is Success -> println("Got: ${result.data}")
    is Error -> println("Error: ${result.message}")
    Loading -> println("Loading...")
    // No else needed! Compiler guarantees exhaustiveness.
}
When to use sealed classes: API response states (Success/Error/Loading), UI states, navigation events, permission results — anywhere you have a fixed set of possibilities and want the compiler to ensure you handle every case.

Inheritance & Interfaces

Classes are final by default in Kotlin. To allow inheritance, mark them open.

// Interface
interface Drawable {
    fun draw()   // abstract by default
    fun describe() = "I can be drawn"   // default implementation
}

// Abstract class
abstract class Shape(val color: String) {
    abstract fun area(): Double
    fun describe() = "A $color shape"
}

// Open class (can be inherited)
open class Rectangle(val width: Double, val height: Double) : Shape("red") {
    override fun area() = width * height

    // Must be marked `open` for subclasses to override
    open fun resize(factor: Double): Rectangle {
        return Rectangle(width * factor, height * factor)
    }
}

class Square(side: Double) : Rectangle(side, side) {
    // ✅ Can override because resize() is marked open
    override fun resize(factor: Double): Rectangle {
        return Square(side * factor)
    }
}

// A class without `open` cannot be subclassed at all
class Circle(val radius: Double) : Shape("blue") {
    override fun area() = Math.PI * radius * radius
    fun scale(factor: Double) { }  // NOT open — cannot be overridden
}
Design philosophy: Kotlin defaults to final because inheritance is powerful but dangerous.

In the example above, resize() must be explicitly marked open for Square to override it, and Circle can't be subclassed at all.

Prefer composition over inheritance.
When to use what:
Interfaces — behavior contracts across unrelated types (e.g., Serializable, Comparable)
Abstract classes — shared base implementation with common state
Open classes — only when you explicitly design for extension

Extension Functions

Add new functions to existing classes without inheritance. This is how Kotlin enhances Java/Android APIs.

// Add a function to String
fun String.addExclamation(): String = this + "!!!"

// Add a function to Int
fun Int.isEven(): Boolean = this % 2 == 0

// Usage
println("hello".addExclamation())   // "hello!!!"
println(4.isEven())                 // true

// Nullable extensions
fun String?.orDefault(default: String = "N/A") = this ?: default

val nullString: String? = null
println(nullString.orDefault())      // "N/A"

Compare with JS / Python

JavaScript
// Monkey-patching (generally discouraged)
String.prototype.addExclamation = function() {
    return this + "!!!";
};

// Or standalone utility
const addExclamation = (s) => s + "!!!";
Python
# Can't truly extend built-in types cleanly
# Usually use standalone functions
def add_exclamation(s: str) -> str:
    return s + "!!!"

# Or monkey-patch (discouraged)
# str.add_exclamation = add_exclamation

Kotlin difference: Extensions are statically resolved, type-safe, and IDE-discoverable. No runtime monkey-patching. They feel like real methods but don't modify the original class.

Real-world extensions: String.isEmail() for validation, View.visible() / View.gone() for Android UI, Int.dpToPx() for density conversion, and List<T>.secondOrNull() for missing collection helpers.

Object Declarations & Companion Objects

// Singleton — guaranteed single instance
object Database {
    fun connect() { println("Connected") }
}

Database.connect()   // No constructor, no 'new'

// Companion object — factory methods and static-like members
class User(val name: String) {
    companion object {
        fun createGuest() = User("Guest")
        const val MAX_NAME_LENGTH = 50
    }
}

val guest = User.createGuest()
println(User.MAX_NAME_LENGTH)
Common patterns: Use object for event buses, shared preferences helpers, and database instances. Use companion object for factory methods (fromJson, empty), parser functions, and constants that belong to the class namespace.
// Inline class — zero overhead wrapper (Kotlin 1.5+)
@JvmInline
value class Password(val value: String)

// The wrapper exists only at compile time — at runtime it's just String
fun authenticate(p: Password) { /* ... */ }
// authenticate("plaintext")  // Type mismatch — compiler catches this!

// Companion object with factory
class User private constructor(val name: String) {
    companion object {
        fun createGuest(): User = User("Guest")
    }
}
val guest = User.createGuest()

Try It Yourself

Create a data class called Book with title, author, and year. Then:

  1. Create a book instance
  2. Use copy() to create a new version with a different year
  3. Write an extension function Book.summary() that returns "{title} by {author}"
// Your code here:





// Expected output:
// The Hobbit by J.R.R. Tolkien (1937)
// The Hobbit by J.R.R. Tolkien (2001)
Stuck? Show hint
data class Book(val title: String, val author: String, val year: Int)
fun Book.summary() = "$title by $author ($year)"
val book = Book("The Hobbit", "J.R.R. Tolkien", 1937)
val newEdition = book.copy(year = 2001)

Concept Check

What's the key difference between a regular class and a data class?

Data classes cannot have methods
Data classes auto-generate equals, hashCode, toString, and copy
Data classes are faster at runtime