Properties and Fields
In Kotlin, you almost never declare a raw field the way you would in Java. Instead, you declare a property with val or var, and the compiler quietly generates the storage and accessor methods for you. Understanding how properties, backing fields, and custom accessors fit together is essential to writing idiomatic Kotlin classes — it is how Kotlin replaces Java’s getter/setter boilerplate with something both safer and shorter, while still compiling down to ordinary getX()/setX() methods on the JVM.
Overview: What Properties Really Are
A Kotlin property is a bundle of up to three things: an optional backing field (the actual storage location), a getter, and, for mutable properties, a setter. When you write var age: Int = 0 inside a class, the compiler automatically generates a default getter and setter and a private backing field to hold the value. When you write val name: String = "Ava", you only get a getter — there is no way to reassign the value from outside, and often not even a real backing field is needed if the value never changes.
You rarely need to write the default getter/setter yourself, but you can override either one. Inside a custom accessor, the special identifier field refers to the backing field. This identifier only exists, and is only legal, inside a property’s own get() or set() block — it is Kotlin’s way of letting you intercept reads or writes without creating infinite recursion by calling the property itself. If a property’s accessors never reference field, the compiler does not generate a backing field at all: the value is computed fresh on every access instead of being stored.
Constructor parameters and properties are related but not the same thing. A primary constructor parameter only becomes a property — visible outside the constructor, and accessible as instance.name — if you mark it with val or var. A plain parameter without either keyword is just a constructor parameter: it can be used inside property initializers and init blocks, but it is not stored anywhere and is not visible from member functions.
class Greeter(name: String) {
val greeting: String = "Hello, $name!"
}
fun main() {
val greeter = Greeter("Kotlin")
println(greeter.greeting)
}
Output:
Hello, Kotlin!
Here name is never exposed as a property — only greeting is. Trying to write greeter.name after construction would fail to compile, because name was never promoted to a property.
On the JVM, every Kotlin property compiles to a private field (if one exists) plus accessor methods, exactly like hand-written Java. This matters for interop: Kotlin code calling into Java sees Java fields and getter/setter pairs as properties automatically, and Java code calling into Kotlin sees ordinary getName()/setName() methods, even though the Kotlin source never wrote them explicitly.
Syntax
The general shape of a property declaration, with optional custom accessors, looks like this:
var propertyName: PropertyType = initializer
get() = field
set(value) {
field = value
}
| Form | Meaning |
|---|---|
val name: Type = value |
Read-only property; compiler generates a getter only. |
var name: Type = value |
Mutable property; compiler generates a getter and setter. |
val name: Type get() = expr |
Computed property with no backing field; recalculated on every read. |
var name: Type = value; set(v) { ... } |
Custom setter, typically validating or transforming v before storing it in field. |
var name: Type = value; private set |
Publicly readable, but only mutable from inside the class. |
lateinit var name: Type |
Deferred initialization of a non-null, non-primitive var declared outside the constructor. |
val name: Type by lazy { ... } |
Computed once, on first access, then cached for all later reads. |
Examples
Example 1: Constructor Properties
The most common case: declaring properties directly in the primary constructor.
class Person(val name: String, var age: Int)
fun main() {
val person = Person("Ava", 30)
println("${person.name} is ${person.age} years old")
person.age = 31
println("${person.name} is now ${person.age}")
}
Output:
Ava is 30 years old
Ava is now 31
name is a val, so it can only be set once, at construction. age is a var, so person.age = 31 compiles and calls the compiler-generated setter behind the scenes.
Example 2: Custom Getters and Setters
class Rectangle(var width: Double, var height: Double) {
val area: Double
get() = width * height
var name: String = "Unnamed"
set(value) {
field = if (value.isBlank()) "Unnamed" else value
}
}
fun main() {
val rect = Rectangle(4.0, 5.0)
println("Area: ${rect.area}")
rect.width = 10.0
println("Area after resize: ${rect.area}")
rect.name = "MyRect"
println("Name: ${rect.name}")
rect.name = " "
println("Name after blank set: ${rect.name}")
}
Output:
Area: 20.0
Area after resize: 50.0
Name: MyRect
Name after blank set: Unnamed
area has no backing field at all — it recomputes width * height on every access, so it automatically stays in sync after width changes. name‘s custom setter validates the incoming value before writing to field, rejecting blank strings.
Example 3: Private Setters for Controlled Mutation
class BankAccount(owner: String, initialBalance: Double) {
val owner: String = owner
var balance: Double = initialBalance
private set
val isOverdrawn: Boolean
get() = balance < 0
fun deposit(amount: Double) {
require(amount > 0) { "Deposit must be positive" }
balance += amount
}
fun withdraw(amount: Double) {
require(amount > 0) { "Withdrawal must be positive" }
balance -= amount
}
}
fun main() {
val account = BankAccount("Sam", 100.0)
println("${account.owner}'s balance: ${account.balance}")
account.deposit(50.0)
println("After deposit: ${account.balance}")
account.withdraw(200.0)
println("After withdrawal: ${account.balance}")
println("Overdrawn? ${account.isOverdrawn}")
}
Output:
Sam's balance: 100.0
After deposit: 150.0
After withdrawal: -50.0
Overdrawn? true
balance can be read from anywhere (account.balance) but only written from inside BankAccount, because of private set. Outside code is forced to go through deposit/withdraw, which enforce validation with require.
Example 4: Lazy Properties
class Config {
val expensiveValue: String by lazy {
println("Computing...")
"computed-result"
}
}
fun main() {
val config = Config()
println("Before access")
println(config.expensiveValue)
println(config.expensiveValue)
}
Output:
Before access
Computing...
computed-result
computed-result
by lazy { ... } is a delegated property: the first read runs the block and caches the result; every later read returns the cached value without rerunning the block. This is why "Computing..." only prints once even though expensiveValue is read twice.
How It Works Step by Step
When the compiler processes a class, it walks its properties top to bottom, in the order they and any init blocks appear in the source. For each property: it determines whether a backing field is needed (only if an accessor references field, or no custom accessor was written at all); it generates a getter (and setter, for var) unless you supplied your own; and, at construction time, it runs each property initializer and init block in textual order, before the constructor body (if any) finishes. This ordering matters: a property or init block cannot use a later property’s value, because it hasn’t been initialized yet.
Sometimes you cannot supply a value at construction time at all — for example, a value injected by a framework, or set up in a test’s @BeforeEach. For a non-null var that will definitely be assigned before first use, but not in the constructor, Kotlin provides lateinit. It skips generating a default value and defers the null-safety guarantee to a runtime check: reading a lateinit property before it’s assigned throws UninitializedPropertyAccessException rather than silently returning null.
class Configuration {
lateinit var environment: String
fun isInitialized(): Boolean = ::environment.isInitialized
}
fun main() {
val config = Configuration()
println("Before init: ${config.isInitialized()}")
config.environment = "production"
println("After init: ${config.isInitialized()}, value = ${config.environment}")
}
Output:
Before init: false
After init: true, value = production
::environment.isInitialized is a reflection-backed check the standard library provides specifically for lateinit properties, so you can test whether the value has been set without triggering the exception.
Common Mistakes
Mistake 1: Calling the Property Instead of the Backing Field
Inside a custom setter, writing the property’s own name instead of field does not update storage — it calls the setter again, recursively, until the stack overflows at runtime. Note that this compiles perfectly fine; the mistake only shows up when you run it.
class Person(name: String) {
var name: String = name
set(value) {
name = value // infinite recursion: calls this same setter again
}
}
Inside the setter body, name resolves to the property itself (the constructor parameter is not visible from member function bodies), so name = value is really this.name = value, which invokes the setter again, and again, producing a StackOverflowError. The fix is to assign to field, which refers to the underlying storage rather than re-invoking the accessor:
class Person(name: String) {
var name: String = name
set(value) {
field = value
}
}
Mistake 2: Using field Outside an Accessor
field is only meaningful inside the get() or set() of the property it belongs to. Referencing it from an ordinary function is a compile-time error, not a runtime one:
import kotlin.math.PI
class Circle(var radius: Double) {
val area: Double
get() = PI * radius * radius
fun resize(newRadius: Double) {
radius = newRadius
field = PI * radius * radius // ERROR: 'field' is only allowed inside a property's own accessor
}
}
The fix is simply to not manage storage manually here: since area is a computed property with no backing field, it already recalculates itself on every access, so resize only needs to update radius.
import kotlin.math.PI
class Circle(var radius: Double) {
val area: Double
get() = PI * radius * radius
fun resize(newRadius: Double) {
radius = newRadius
}
}
fun main() {
val circle = Circle(2.0)
println("Area: ${"%.2f".format(circle.area)}")
circle.resize(4.0)
println("Area after resize: ${"%.2f".format(circle.area)}")
}
Output:
Area: 12.57
Area after resize: 50.27
Mistake 3: Assuming val Makes a Collection Immutable
val only protects the reference — it stops you from reassigning the variable to a different object. It says nothing about whether the object itself can be mutated. A val holding a MutableList can still have items added or removed:
val readOnly = listOf(1, 2, 3)
readOnly.add(4) // ERROR: unresolved reference 'add' — List has no add() method
This fails to compile because listOf() returns the read-only List interface, which has no mutating methods at all — the immutability here comes from the type, not the val keyword. If mutation is actually what you want, use mutableListOf() instead, and understand that the val is protecting only the variable binding, not the list’s contents:
val mutableExample = mutableListOf(1, 2, 3)
mutableExample.add(4)
println(mutableExample)
Output:
[1, 2, 3, 4]
Best Practices
- Prefer
valovervarfor properties; only usevarwhen the value genuinely needs to change after construction. - Only write a custom getter or setter when you need computation or validation — if it would just mirror the default behavior, leave it out.
- Always assign to
fieldinside a custom accessor, never to the property’s own name, to avoid infinite recursion. - Use
private set(orinternal set) to expose a property as read-only externally while still allowing controlled mutation from inside the class. - Prefer
by lazy { }for expensive one-time computations over eagerly computing them in aninitblock. - Reserve
lateinit varfor values you truly cannot supply in the constructor (dependency injection, test fixtures, framework callbacks) — never as a workaround for null-safety. - Don’t expose a mutable collection directly from a property if callers shouldn’t be able to change it; expose a read-only
List/Mapview or a defensive copy instead. - Keep custom getters cheap and side-effect-free; callers expect
obj.propertyto behave like fast field access, not a slow computation or I/O call.
Practice Exercises
1. Write a Temperature class with a var celsius: Double property and a computed var fahrenheit: Double property whose getter converts from Celsius (celsius * 9 / 5 + 32) and whose setter converts a Fahrenheit value back into Celsius and stores it. Verify that changing celsius changes what fahrenheit reports, and vice versa.
2. Write a Wallet class with a balance property that has a public getter but a private setter, plus deposit and withdraw functions that use require to reject non-positive amounts. Confirm that code outside the class cannot write wallet.balance = 1000.0 directly.
3. Given a Counter class with var count = 0 using private set and an increment() function that does count++, predict what gets printed if you create a Counter, call increment() three times, and print count. Then check your answer by writing and compiling the code yourself.
Summary
- A property is an optional backing field plus a getter (and, for
var, a setter) — the compiler generates these automatically unless you override them. valproduces a read-only property (getter only);varproduces a mutable one (getter and setter).- A primary constructor parameter only becomes a property if declared with
valorvar; otherwise it’s just a constructor parameter. fieldrefers to the backing storage and is only valid inside that property’s own accessor.- A property whose accessor never references
fieldhas no backing field and recomputes its value on every access. private setlets you expose a property as publicly readable but only internally writable.lateinitandby lazyhandle two different deferred-initialization needs for non-null properties.valprotects the reference, not the contents, of a mutable object like aMutableList.
