Mastering Kotlin Properties: From Basics to Advanced Semantics

If you’re a middle dev, you probably use val and var daily, but have you ever wondered why Kotlin doesn’t have traditional fields like Java? Or how to avoid those pesky initialization errors? We’ll cover all that and more. By the end, you’ll design more predictable and efficient Kotlin code.

This post is structured around the following table of contents for easy navigation:

Let’s get started. I’ll include full code examples throughout, so you can copy-paste and experiment in your IDE.

Properties vs Fields in Kotlin

In Java, we have fields: simple class members that store data, like private int age;. You access them directly or via getters/setters. Kotlin flips this script with properties. A property is a higher-level abstraction that combines a field (if needed) with accessors.

Why the change? Kotlin aims for conciseness and safety. Properties let you declare val (immutable) or var (mutable) with automatic getters/setters. No more boilerplate!

Consider this basic example:

Kotlin

class Person {
    var name: String = "Alex"  // This is a property
    val age: Int = 35          // Immutable property
}

fun main() {
    val person = Person()
    println(person.name)       // Calls implicit getter
    person.name = "Jordan"     // Calls implicit setter
    println(person.name)       // Outputs: Jordan
    // person.age = 36         // Compile error: val cannot be reassigned
}

Here, name and age aren’t just fields—they’re properties. Under the hood, Kotlin generates a private backing field (more on that later) and public getter/setter methods. For val age, only a getter is generated; no setter.

Key differences from Java fields:

  • Encapsulation by default: Properties can have custom logic in accessors without exposing the underlying storage.
  • Uniform access: You use dot notation (person.name) instead of method calls like person.getName().
  • Immutability: val enforces read-only at compile time, reducing bugs from accidental mutations.

For middle devs: If you’re coming from Java, think of properties as auto-generated getters/setters with optional backing storage. This design prevents direct field access, promoting better API design. But beware: overusing mutable var can lead to stateful bugs. Prefer val where possible.

In performance-critical code, properties add negligible overhead since they’re inlined by the compiler.

Backing Fields and When They Exist

Not every property has a backing field. A backing field is the private variable that stores the property’s value. Kotlin generates one only if it’s needed—i.e., if the property has a default getter/setter or if you reference it explicitly.

When does it exist?

  • For var or val with an initializer: Yes, backing field created.
  • If you override getters/setters without referencing field: No backing field—it’s a computed property.
  • Explicit use of field keyword forces a backing field.

Example of a property with backing field:

Kotlin

class Counter {
    var count: Int = 0
        get() = field       // References backing field
        set(value) {
            if (value >= 0) field = value  // Updates backing field
        }
}

fun main() {
    val counter = Counter()
    counter.count = 5
    println(counter.count)  // 5
    counter.count = -1      // Ignored, still 5
    println(counter.count)  // 5
}

Here, field is the backing field. Without it, you’d get a stack overflow from recursive getter calls.

Now, a computed property without backing field:

Kotlin

class Rectangle(val width: Int, val height: Int) {
    val area: Int
        get() = width * height  // No 'field' needed
}

fun main() {
    val rect = Rectangle(4, 5)
    println(rect.area)  // 20
    // rect.area = 10   // No setter, and no backing field
}

For middle devs: Always use field in custom accessors to avoid recursion. Backing fields are private and JVM-visible only if annotated (see section 7). If your property is read-only and computed, skip the backing field for memory efficiency—great for large objects.

Common pitfall: Forgetting field in setters leads to infinite loops. Debug by checking decompiled Java code in IntelliJ (Tools > Kotlin > Show Kotlin Bytecode).

Custom Getters and Setters

Kotlin shines with custom accessors. You can add logic without separate methods, keeping APIs clean.

Basic custom getter:

Kotlin

class Temperature {
    var celsius: Double = 0.0
    val fahrenheit: Double
        get() = celsius * 9 / 5 + 32  // Custom getter
}

fun main() {
    val temp = Temperature()
    temp.celsius = 25.0
    println(temp.fahrenheit)  // 77.0
}

Custom setter with validation:

Kotlin

class User {
    var email: String = ""
        set(value) {
            if (value.contains("@")) {
                field = value
            } else {
                throw IllegalArgumentException("Invalid email")
            }
        }
}

fun main() {
    val user = User()
    user.email = "alex@example.com"  // OK
    println(user.email)
    // user.email = "invalid"        // Throws exception
}

You can also make setters private for controlled mutation:

Kotlin

class ImmutableList<T>(private val internalList: MutableList<T> = mutableListOf()) {
    val size: Int
        get() = internalList.size

    fun add(item: T) {
        internalList.add(item)  // Internal mutation
    }
}

fun main() {
    val list = ImmutableList<String>()
    list.add("Item1")
    println(list.size)  // 1
    // No direct setter for size
}

For middle devs: Custom accessors are powerful for invariants (e.g., non-negative values). But overcomplicating them can hide logic—keep them simple. Use them for lazy computation too:

Kotlin

class LazyExample {
    val expensiveValue: String by lazy {
        println("Computing...")
        "Computed Value"
    }
}

fun main() {
    val example = LazyExample()
    println(example.expensiveValue)  // Computing... \n Computed Value
    println(example.expensiveValue)  // Computed Value (cached)
}

by lazy delegates to a backing property that’s initialized on first access.

Initialization Order and Constructor Phases

Kotlin’s initialization is strict: Properties must be initialized before use. This happens in phases during object creation.

Phases:

  1. Primary constructor: Parameters are available, but body not executed yet.
  2. Init blocks and property initializers: Run in declaration order.
  3. Secondary constructors: Call primary and run their bodies.

Example showing order:

Kotlin

class InitOrder(val param: Int) {
    val prop1 = println("Prop1 init with param: $param")  // Runs after param set
    init {
        println("Init block 1")
    }
    val prop2 = println("Prop2 init")
    init {
        println("Init block 2")
    }

    constructor() : this(42) {
        println("Secondary constructor body")
    }
}

fun main() {
    InitOrder()  // Output:
                 // Prop1 init with param: 42
                 // Init block 1
                 // Prop2 init
                 // Init block 2
                 // Secondary constructor body
}

Note: Property initializers and init blocks interleave in source order.

For middle devs: Avoid circular dependencies in initializers—e.g., propA depending on propB which depends on propA. Use lateinit (next section) for delayed init. In inheritance, super init happens first.

Common error: Accessing uninitialized properties in init blocks. Kotlin compiler catches most, but runtime NPEs can occur if sneaky.

Full example with inheritance:

Kotlin

open class Base {
    init {
        println("Base init")
    }
}

class Derived : Base() {
    val derivedProp = println("Derived prop init")
    init {
        println("Derived init")
    }
}

fun main() {
    Derived()  // Base init \n Derived prop init \n Derived init
}

lateinit vs Nullable Properties

Initialization can be tricky for properties set post-construction (e.g., in DI or Android lifecycles). Two options: lateinit or nullable types.

lateinit: For non-null properties initialized later. Compiler skips init check, but accessing before set throws exception.

Kotlin

class DatabaseConnection {
    lateinit var connection: String

    fun connect() {
        connection = "Connected to DB"
    }

    fun query() {
        println(connection)  // OK after connect()
    }
}

fun main() {
    val db = DatabaseConnection()
    db.connect()
    db.query()  // Connected to DB
    // db.query() without connect() -> UninitializedPropertyAccessException
}

Nullable: Use ? for optional values, with safe calls.

Kotlin

class OptionalConfig {
    var config: String? = null

    fun loadConfig() {
        config = "Loaded"
    }

    fun useConfig() {
        println(config?.length ?: 0)  // Safe, handles null
    }
}

fun main() {
    val config = OptionalConfig()
    config.useConfig()  // 0
    config.loadConfig()
    config.useConfig()  // 6
}

Comparison:

  • lateinit: For mandatory non-null, assumes init before use. No null checks needed later. Only for var, not primitives.
  • Nullable: For optional or uncertain init. Requires null handling (e.g., ?., !!, Elvis).

For middle devs: Prefer nullable for safety—avoids runtime crashes. Use lateinit in frameworks like Android where init is guaranteed (e.g., lateinit var viewModel: MyViewModel). Check with ::property.isInitialized.

Example checking init:

Kotlin

class CheckInit {
    lateinit var value: String

    fun setValue() {
        value = "Set"
    }

    fun printIfInitialized() {
        if (::value.isInitialized) {
            println(value)
        } else {
            println("Not initialized")
        }
    }
}

fun main() {
    val check = CheckInit()
    check.printIfInitialized()  // Not initialized
    check.setValue()
    check.printIfInitialized()  // Set
}

const val and Compile-time Constants

const val declares compile-time constants, baked into bytecode. Unlike regular val, they’re evaluated at compile time and can be used in annotations.

Requirements:

  • Top-level or object member.
  • Primitive type or String.
  • No custom getter.

Example:

Kotlin

const val APP_VERSION = "1.0"  // Compile-time constant

class Constants {
    companion object {
        const val MAX_USERS = 100
    }
}

annotation class Version(val value: String)

@Version(APP_VERSION)  // OK, compile-time
class MyClass

fun main() {
    println(APP_VERSION)  // 1.0
    println(Constants.MAX_USERS)  // 100
}

Regular val can’t be used in annotations:

Kotlin

val runtimeConst = "Runtime"  // Not const

// @Version(runtimeConst)  // Compile error

For middle devs: Use const for performance in constants used often (e.g., enums, configs). They’re inlined, reducing method calls. But they’re public by default—careful with interop. No runtime computation allowed:

Kotlin

// const val NOW = System.currentTimeMillis()  // Error: not constant

JVM Field Visibility and Interop

Kotlin compiles to JVM bytecode, so properties become fields or methods. For Java interop, use annotations like @JvmField to expose backing fields.

Without it, properties are getter/setter methods:

Kotlin:

Kotlin

class KotlinClass {
    var property: String = "Value"
}

Java view: public String getProperty(); public void setProperty(String); (no field).

With @JvmField:

Kotlin

class ExposedClass {
    @JvmField
    var exposed: String = "Exposed"
}

Java: Direct field access public String exposed;.

For visibility: Properties are public by default, but backing fields are private. Use @JvmSynthetic to hide from Java.

Full interop example:

Kotlin:

Kotlin

class Interop {
    @JvmField
    val jvmField: Int = 42

    var normalProp: String = "Normal"
}

object Singleton {
    @JvmStatic
    fun staticMethod() = "Static"
}

Java usage:

Java

public class JavaUsage {
    public static void main(String[] args) {
        Interop interop = new Interop();
        System.out.println(interop.jvmField);  // Direct field
        System.out.println(interop.getNormalProp());  // Getter

        System.out.println(Singleton.staticMethod());  // Static call
    }
}

For middle devs: Use @JvmField sparingly—breaks encapsulation. Essential for performance in libraries like Android parcels. For const, they’re automatically JVM fields.

Designing Predictable Property APIs

Finally, let’s talk design. Predictable properties avoid surprises: Use val for immutability, custom accessors for logic, and clear naming.

Best practices:

  • Immutable by default: Favor val over var.
  • Delegate wisely: Use lazy, observable for reactive props.
  • Avoid side effects: Getters should be pure; no mutations.
  • Thread safety: For shared props, use @Volatile or mutexes.
  • Documentation: Javadoc for public props.

Example of a well-designed class:

Kotlin

import kotlin.properties.Delegates

class UserProfile {
    val id: Int  // Immutable

    var name: String by Delegates.observable("") { _, old, new ->
        println("Name changed from $old to $new")
    }

    val fullName: String
        get() = "$name (ID: $id)"  // Computed, pure

    constructor(id: Int) {
        this.id = id
    }
}

fun main() {
    val profile = UserProfile(1)
    profile.name = "Alex"  // Name changed from  to Alex
    println(profile.fullName)  // Alex (ID: 1)
}

For middle devs: Test properties thoroughly—unit tests for getters/setters. In APIs, version changes if mutability shifts.

In summary, mastering properties elevates your Kotlin code from functional to elegant. Experiment with these concepts, and you’ll catch bugs early. If you have questions, drop a comment!

Leave a Reply

Your email address will not be published. Required fields are marked *