cyberangles guide

Kotlin's Built-In Delegates: Enhancing Property Behavior

In Kotlin, properties are more than just variables—they can be empowered with reusable behavior through **delegation**. Delegation allows you to offload the logic for getting/setting a property to another object (the "delegate"), enabling clean encapsulation of common patterns like lazy initialization, change observation, validation, and more. Instead of writing boilerplate code for these patterns repeatedly, Kotlin provides a set of **built-in delegates** that handle these tasks out of the box. This blog explores Kotlin’s built-in delegates in depth, explaining their use cases, behavior, and how they can simplify your code. Whether you’re new to Kotlin or looking to level up your property management skills, this guide will help you leverage delegates to write cleaner, more maintainable code.

Table of Contents

  1. Introduction to Delegates in Kotlin
  2. Understanding the Delegate Pattern
  3. Kotlin’s Built-In Delegates
  4. Advanced Use Cases and Best Practices
  5. Conclusion
  6. References

Introduction to Delegates in Kotlin

At its core, a delegate is an object that assumes responsibility for handling the get and set operations of a property. Kotlin natively supports property delegation via the by keyword, allowing you to decouple property logic from the class that declares the property. This promotes code reuse: instead of rewriting logic like “validate age” or “log changes” for every property, you can encapsulate it in a delegate and reuse it across multiple properties.

For example, if you want multiple properties to log changes when modified, you can create a LoggingDelegate once and delegate those properties to it. Kotlin’s built-in delegates take this a step further by providing pre-built solutions for common scenarios, eliminating the need to write custom delegates for everyday tasks.

Understanding the Delegate Pattern

Before diving into built-in delegates, let’s ground ourselves in the delegate pattern, a design pattern where an object (the “delegate”) acts on behalf of another object (the “delegator”). In Kotlin, property delegation is formalized with two interfaces:

  • ReadOnlyProperty<ThisRef, Value>: For read-only properties (val). Defines a getValue(thisRef: ThisRef, property: KProperty<*>): Value method to handle property retrieval.
  • ReadWriteProperty<ThisRef, Value>: For mutable properties (var). Extends ReadOnlyProperty and adds a setValue(thisRef: ThisRef, property: KProperty<*>, value: Value) method to handle property modification.

A Simple Custom Delegate Example

To illustrate, let’s create a custom LoggingDelegate that logs property access and modifications:

import kotlin.reflect.KProperty

class LoggingDelegate<T>(private var value: T) : ReadWriteProperty<Any?, T> {
    // Handles property retrieval
    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        println("Getting ${property.name}: $value")
        return value
    }

    // Handles property modification
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        println("Setting ${property.name} from ${this.value} to $value")
        this.value = value
    }
}

// Usage: Delegate a property to LoggingDelegate
var score: Int by LoggingDelegate(0)

fun main() {
    score = 42 // Logs: "Setting score from 0 to 42"
    println(score) // Logs: "Getting score: 42" and prints 42
}

Here, score delegates its get/set logic to LoggingDelegate, which encapsulates the logging behavior. This is the essence of delegation in Kotlin. Now, let’s explore Kotlin’s built-in delegates, which solve common problems like lazy initialization and validation.

Kotlin’s Built-In Delegates

Kotlin provides a set of ready-to-use delegates in the kotlin.properties.Delegates object and the lazy function. Let’s break down each one.

3.1 lazy: Lazy Initialization

The lazy delegate defers property initialization until the first time it is accessed. This is ideal for expensive operations (e.g., database calls, network requests) that should only run when needed.

How It Works

  • The lazy function takes a lambda that computes the property’s initial value.
  • The lambda runs once, the first time the property is accessed.
  • Subsequent accesses return the cached value.

Basic Usage

val expensiveValue: String by lazy {
    println("Computing expensive value...") // Runs once
    "Result of expensive computation"
}

fun main() {
    println("Before access")
    println(expensiveValue) // Triggers initialization: "Computing..." and "Result..."
    println(expensiveValue) // Uses cached value: "Result..." (no computation)
}

Thread Safety Modes

By default, lazy is thread-safe: it uses LazyThreadSafetyMode.SYNCHRONIZED, which locks the initialization to ensure only one thread computes the value. For single-threaded contexts or performance-critical code, you can customize the thread safety mode:

ModeBehaviorUse Case
SYNCHRONIZED (default)Initialization is synchronized; only one thread executes the lambda.Multi-threaded environments (e.g., Android, server apps).
NONENo synchronization; unsafe for multi-threaded use.Single-threaded contexts (e.g., unit tests, local scripts).
PUBLICATIONAllows multiple threads to run the lambda, but the first result is used.When the lambda is idempotent (repeated runs produce the same result) and you want faster initialization in multi-threaded scenarios.

Example: Thread Safety Modes

// Thread-unsafe (use only in single-threaded code)
val unsafeLazy: Int by lazy(LazyThreadSafetyMode.NONE) {
    println("Unsafe initialization")
    42
}

// Publication mode (idempotent lambda)
val publicationLazy: String by lazy(LazyThreadSafetyMode.PUBLICATION) {
    println("Publication initialization")
    "Hello"
}

3.2 observable: Observing Property Changes

The Delegates.observable delegate tracks changes to a mutable property (var). It triggers a change handler after the property is updated, allowing you to react to new values (e.g., logging, updating UI).

Signature

fun <T> observable(initialValue: T, onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Unit): ReadWriteProperty<Any?, T>
  • initialValue: The property’s starting value.
  • onChange: A lambda invoked after the property is updated. It receives:
    • property: Metadata about the property (e.g., name).
    • oldValue: The previous value.
    • newValue: The updated value.

Example: Logging User Profile Changes

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("Guest") { prop, old, new ->
        println("${prop.name} changed from '$old' to '$new'")
    }
    var age: Int by Delegates.observable(0) { prop, old, new ->
        println("${prop.name} changed from $old to $new")
    }
}

fun main() {
    val user = User()
    user.name = "Alice" // Logs: "name changed from 'Guest' to 'Alice'"
    user.age = 30 // Logs: "age changed from 0 to 30"
}

3.3 vetoable: Validating Property Changes

Like observable, Delegates.vetoable tracks property changes, but it allows you to block invalid updates. The change handler returns a Boolean: true to allow the update, false to reject it.

Signature

fun <T> vetoable(initialValue: T, onVeto: (property: KProperty<*>, oldValue: T, newValue: T) -> Boolean): ReadWriteProperty<Any?, T>
  • onVeto: A lambda invoked before the property is updated. Returns true to proceed with the change, false to veto it.

Example: Validating Age

import kotlin.properties.Delegates

class Person {
    // Age can't be negative or exceed 150
    var age: Int by Delegates.vetoable(0) { _, old, new ->
        new in 0..150 // Allow only if new age is between 0 and 150
    }
}

fun main() {
    val person = Person()
    person.age = 25 // Allowed: age becomes 25
    person.age = -5 // Vetoed: age remains 25
    person.age = 200 // Vetoed: age remains 25
    println(person.age) // 25
}

3.4 notNull: Non-Null Properties with Late Initialization

The Delegates.notNull delegate is for mutable properties (var) that must be non-null but cannot be initialized immediately (e.g., properties initialized by dependency injection or in onCreate). It throws an exception if accessed before initialization, ensuring non-null safety.

Key Notes

  • Unlike lateinit var, notNull works with all types (including primitives like Int or Boolean), whereas lateinit is limited to non-primitive reference types.
  • Accessing an uninitialized notNull property throws IllegalStateException.

Example: notNull vs. lateinit

import kotlin.properties.Delegates

class Service {
    // Using notNull for a primitive type (Int)
    var port: Int by Delegates.notNull()

    // Using lateinit for a reference type (String)
    lateinit var host: String
}

fun main() {
    val service = Service()
    
    // service.port // Throws: "Property port should be initialized before get."
    // service.host // Throws: "lateinit property host has not been initialized"

    service.port = 8080
    service.host = "localhost"
    println("${service.host}:${service.port}") // "localhost:8080"
}

3.5 Map Delegates: Storing Properties in Maps

Map delegates allow you to store property values in a Map (for read-only properties) or MutableMap (for mutable properties). This is useful for dynamic data (e.g., parsing JSON, configuration files) where property names are not known at compile time.

Read-Only Map Delegates

For val properties, delegate to a Map<String, *>. The property name must match a key in the map (case-sensitive).

class Config(settings: Map<String, Any>) {
    val apiUrl: String by settings // Key: "apiUrl"
    val timeout: Int by settings   // Key: "timeout"
    val debugMode: Boolean by settings // Key: "debugMode"
}

fun main() {
    val configMap = mapOf(
        "apiUrl" to "https://example.com",
        "timeout" to 5000,
        "debugMode" to true
    )
    val config = Config(configMap)
    println(config.apiUrl) // "https://example.com"
    println(config.timeout) // 5000
}

Mutable Map Delegates

For var properties, delegate to a MutableMap<String, *>. Updates to the property will modify the map, and vice versa.

class MutableConfig(settings: MutableMap<String, Any>) {
    var apiUrl: String by settings
    var timeout: Int by settings
}

fun main() {
    val mutableConfigMap = mutableMapOf(
        "apiUrl" to "https://old.com",
        "timeout" to 3000
    )
    val mutableConfig = MutableConfig(mutableConfigMap)

    mutableConfig.apiUrl = "https://new.com" // Updates the map
    println(mutableConfigMap["apiUrl"]) // "https://new.com" (map reflects change)

    mutableConfigMap["timeout"] = 6000 // Updates the property
    println(mutableConfig.timeout) // 6000 (property reflects map change)
}

3.6 observable vs. vetoable: When to Use Which?

observablevetoable
Reacts after a change (e.g., log, notify listeners).Intercepts changes before they occur (e.g., validate, block invalid values).
Handler returns Unit (no control over the change).Handler returns Boolean (controls whether the change is allowed).
Use case: Tracking state changes (e.g., “user updated profile”).Use case: Enforcing business rules (e.g., “age cannot be negative”).

Advanced Use Cases and Best Practices

Combining Delegates

You can chain delegates to combine behaviors (e.g., lazy + observable). However, Kotlin does not support by chaining directly, so you’ll need a helper delegate to compose them.

Example: A lazy-initialized property that logs changes:

class LazyObservableDelegate<T>(
    private val initializer: () -> T,
    private val onChange: (old: T, new: T) -> Unit
) : ReadWriteProperty<Any?, T> {
    private val lazyValue by lazy(initializer)
    private var currentValue: T? = null

    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        val value = lazyValue
        currentValue = value
        return value
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        val oldValue = currentValue ?: error("Property not initialized")
        onChange(oldValue, value)
        currentValue = value
    }
}

// Usage
var lazyObservableProp: String by LazyObservableDelegate(
    initializer = { "Initial" },
    onChange = { old, new -> println("Changed from $old to $new") }
)

Best Practices

  • Choose lazy thread safety wisely: Use SYNCHRONIZED for multi-threaded safety, NONE only in single-threaded code, and PUBLICATION for idempotent initializers.
  • Avoid side effects in lazy initializers: The lambda should be pure (no side effects like logging) to prevent unexpected behavior.
  • Prefer vetoable over manual checks: Use vetoable to encapsulate validation logic, making it reusable across properties.
  • Map delegates for dynamic data: Use map delegates when working with JSON, configs, or other dynamic key-value data to avoid boilerplate parsing.

Conclusion

Kotlin’s built-in delegates are powerful tools for enhancing property behavior with minimal code. By leveraging lazy, observable, vetoable, notNull, and map delegates, you can eliminate boilerplate, enforce consistency, and encapsulate common logic like initialization, validation, and observation.

Whether you’re building a mobile app, backend service, or CLI tool, these delegates will help you write cleaner, more maintainable code. Experiment with them, combine them, and explore custom delegates to solve unique problems—delegation is a cornerstone of idiomatic Kotlin!

References