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
class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
const user = new User("Alex", 25);
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.
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")
val in data classes. When you need to "change" something, use .copy(). This makes your code predictable and thread-safe.
equals(), hashCode(), toString(), and copy(). Only primary constructor parameters are used.
Compare with JS / Python
// 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" };
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
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>().
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
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!
}
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.
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)
}
}
}
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"
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.
}
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
}
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.
• 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
// Monkey-patching (generally discouraged)
String.prototype.addExclamation = function() {
return this + "!!!";
};
// Or standalone utility
const addExclamation = (s) => s + "!!!";
# 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.
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)
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()
📖 Official docs: Classes and Inheritance (kotlinlang.org) · Data Classes (kotlinlang.org) · Sealed Classes (kotlinlang.org)
Try It Yourself
Create a data class called Book with title, author, and year. Then:
- Create a book instance
- Use
copy()to create a new version with a different year - 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?