Properties, lazy & lateinit Explained

KOTLIN › Language Idioms

A Kotlin property is never just a field. Even val x = 5 compiles down to a **getter**, and a var compiles down to a getter and a setter. That distinction is why properties come up so often in interviews: they look trivial, but they expose in seconds whether you actually know what the compiler is doing for you.

Three keywords set the ground rules:

- val is read-only. You assign it exactly once, either inline at declaration, inside an init block, or in the constructor, and it can never be reassigned after that. - var is mutable. You can assign and reassign it anywhere it's in scope, as many times as you like. - const val is a **compile-time** constant. Its value has to be knowable when the code is compiled, so it's restricted to a String or primitive type, it can never have a custom getter, and it can only be declared at top level, inside an object, or inside a companion object, never as an ordinary instance member and never as a local variable inside a function.

const val MAX_RETRIES = 3       // top-level, compile-time constant
val retryCount = computeCount() // runtime val, still read-only

The rest of this lesson is about what a property actually does underneath its getter and setter, and the tools Kotlin gives you for delaying when a value gets set: lazy, lateinit, and one more delegate that fills the gap between them.

**lazy defers the initialization of a val.** Wrap the initializer in lazy { ... } and Kotlin runs that block the first time the property is read, then caches the result for every read after that:

val config: String by lazy {
    println("computing...")
    loadConfig()
}

The first access prints computing... and computes the value. Every later access just returns the cached String, the block never runs again.

Two constraints follow directly from how lazy works. It only ever supplies a getValue, never a setValue, so it can back a val and never a var. And the property can't have a custom getter of its own, because lazy already is the getter.

This is the standard fix for 'expensive to build, but not needed right away': a compiled regex, a parsed config, a RecyclerView diff callback.

**lazy also takes a thread-safety mode.** Left at its default, lazy { ... } uses LazyThreadSafetyMode.SYNCHRONIZED: if two threads race to read the property for the first time, a lock ensures only one of them actually runs the initializer, while the other waits and receives the same cached result.

Two alternatives exist for when you know more about your threading:

val a by lazy(LazyThreadSafetyMode.PUBLICATION) { build() }  // may race, first result wins
val b by lazy(LazyThreadSafetyMode.NONE) { build() }          // no locking at all

PUBLICATION lets multiple threads run the initializer concurrently and just accepts whichever result gets published first. It's cheaper than a lock, but the block might run more than once. NONE removes synchronization entirely, correct only when you're certain the property is read from a single thread, and it's the fastest option of the three.

**lateinit defers the initialization of a var.** Instead of computing a value lazily on first read, you promise the compiler you'll assign it yourself before anything reads it, typically in onCreate, a DI setup step, or a test's @Before:

class MainActivity : AppCompatActivity() {
    lateinit var adapter: RecyclerView.Adapter<*>

    override fun onCreate(savedInstanceState: Bundle?) {
        adapter = MyAdapter()   // must run before adapter is read
    }
}

Under the hood, lateinit uses a null sentinel to represent 'not yet set', and that's exactly where its two hard constraints come from. The property must be a var, since it needs a setter to ever get assigned. And its type must be a **non-null reference type**: primitives like Int and Boolean have no null value to use as a sentinel, and a nullable type could just be initialized to null directly and skip the mechanism entirely.

Two more restrictions round it out: lateinit can never appear as a parameter in a primary constructor, since that would defeat the point of assigning it later, and it can never have a custom get or set body, it always uses the plain default accessor. Beyond that it's flexible: it works on a class member, a top-level property, or a local variable, as long as it's a var.

**Reading a lateinit property before it's assigned doesn't return null, it throws.** Kotlin raises a dedicated UninitializedPropertyAccessException:

class Repo {
    lateinit var name: String

    fun greet() = println(name)  // throws if called before assignment
}

You can't check this with name != null, the type is non-null, so that comparison wouldn't even compile. Instead, check it with a **property reference**: this::name.isInitialized. It returns true once the property has been assigned, and it's only callable from where the property itself is in scope.

if (this::name.isInitialized) {
    println(name)
} else {
    println("not yet ready")
}

**A backing field is the actual storage slot behind a property, and it isn't always generated.** Inside a custom accessor, the identifier field refers to that storage, you use it to read or write the stored value without recursively calling the accessor itself:

var bio: String = ""
    set(value) {
        field = value.trim()   // stores into the backing field
        // bio = value.trim() would recurse into this same setter
    }

Kotlin only generates a backing field when one is actually needed: when the property uses the default accessor, or when a custom accessor references field at least once. A property with a fully computed getter that never touches field gets no storage at all, its value is recomputed on every read instead:

val area: Int get() = width * height   // no backing field, computed fresh every time

**You can restrict who can write a property without hiding that it can be read.** Annotate just the setter:

class Account {
    var balance: Int = 0
        private set

    fun deposit(amount: Int) { balance += amount }
}

Outside code can read account.balance freely, but only Account itself can assign to it, account.balance = 100 from outside is a compile error. Only the accessor you annotate changes visibility, the getter stays exactly as public as the property itself.

The same idea, applied to a whole mutable collection, is the **backing-property pattern**: keep a private mutable version, and expose a public read-only view over it, conventionally with a leading underscore on the private one.

private val _items = mutableListOf<String>()
val items: List<String> get() = _items   // read-only view, same underlying list

Callers see a List<String> with no add or remove, while the class itself keeps mutating _items directly.

**lateinit rejects primitives, but you often still need 'assign it later' on an Int or a Boolean.** Delegates.notNull() from kotlin.properties fills exactly that gap: a var delegate that behaves like lateinit, throwing if read before assignment, but works with any type because it doesn't rely on lateinit's null-sentinel trick.

import kotlin.properties.Delegates

var retryCount: Int by Delegates.notNull()

fun configure(n: Int) { retryCount = n }
fun run() { repeat(retryCount) { attempt() } }  // safe once configure() ran

Reach for it exactly when you want lateinit's semantics, assign later, throw if someone reads it too early, on a type lateinit can't support directly.

Every tool in this lesson answers the same underlying question: when does a property's value exist, and who's allowed to set it? val and var set the baseline: assigned once versus reassignable. const val pushes that further to compile time, inlined everywhere it's used, which is why it's limited to top-level, object, or companion declarations of a String or primitive.

For runtime values you can't provide immediately, you're choosing between three tools, and the choice comes down to two questions: is it a val or a var, and is the type a primitive?

- val, computed once and cached on first read: lazy. - var, non-null reference type, assigned later by your own code: lateinit. - var, any type including primitives, assigned later by your own code: Delegates.notNull().

Backing fields and private set sit at a different layer: they don't control *when* a value shows up, they control *how it's stored* and *who can change it* once it does. A computed getter that never touches field has no storage at all and reruns every time; a private set keeps the getter public while locking writes to the declaring class.

In an interview, the strongest answer isn't 'lazy is for caching', it's naming the actual constraint that decides between these: read-only or mutable, compile-time or runtime, computed or assigned, primitive or reference type, and who owns the write.

Back to Properties, lazy & lateinit