CodeGym /Courses /Kotlin SELF /Getters and setters in Kotlin: private set, computed prop...

Getters and setters in Kotlin: private set, computed properties, and field

Kotlin SELF
Level 32, Lesson 1
Available

1. A property in Kotlin is a small API

If you’re just starting out, it’s very easy to think like this: “var x is just a variable that lives inside an object.” In an everyday sense, that’s almost true… until you find out that reading and writing a property are operations that can be controlled. Kotlin is designed so you build an object not as a “bag of data,” but as a set of safe actions.

It’s important to catch the idea: when you write obj.name, you don’t “pull a field from memory” directly (at least conceptually). You invoke a property read, i.e., its getter. When you write obj.name = "...", you invoke a property write, i.e., its setter. That’s exactly why Kotlin lets you override read/write behavior: you’re writing a small “contract” around the property.

In the Kotlin docs, you can even see this “in the compiler’s footprints”: when a property has read/write logic, functions essentially appear: get() and set(value) (this is especially visible in examples of code generation for delegated properties, where get()/set(value) are shown explicitly as the accessor form).

To feel the mechanics, here’s a mini diagram:

flowchart LR
    A["Outside code: obj.x"] --> B["get()"]
    C["Outside code: obj.x = v"] --> D["set(v)"]
    B --> E["return value"]
    D --> F["checks / normalization / write"]

2. Controlling access and the property’s “presentation”

private set: readable by everyone, writable only inside the class

Sometimes you want a simple thing: let the property be readable from anywhere, but writable only inside the object. This is a super-popular pattern because it supports encapsulation extremely well. For example: an account balance, number of attempts, an internal ID counter—these are usually not something you want external code to assign arbitrarily.

private set is like a glass display case: everyone can look, but only staff can touch.

A minimal example: “the balance can only be topped up”:

class Wallet {
    var balanceCents: Int = 0
        private set

    fun deposit(amountCents: Int) {
        require(amountCents > 0) { "amountCents must be > 0" }
        balanceCents += amountCents
    }
}

fun main() {
    val w = Wallet()
    w.deposit(250)
    println(w.balanceCents) // 250
}

Notice an important detail: we don’t forbid reading balanceCents, but we forbid direct writing like w.balanceCents = 1_000_000. Now the balance changes only through a clear action deposit(...), which means you always have a control point (checks, logging, limits—anything you need).

How private set differs from private var:

private var means “from the outside, the property doesn’t exist at all.”
var ... private set means “from the outside, the property exists, but it’s read-only.”

When you design a class API, these feel very different for the user. And most of the time, “you can see it, but you can’t change it” is exactly what you want.

Computed properties: a showcase property over data

Sometimes you need to store data in one format (for example, in cents), but show it to the user in another (in dollars, with two digits). Or you need a nice display text, but you don’t want to store it separately to avoid a second source of truth (and having to sync one with the other manually).

That’s where a computed property comes in: a property that does not store a value, but computes it in get().

By the way, Kotlin even has a style guideline: prefer a property over a function when the computation is cheap, doesn’t throw, and returns the same result as long as the object’s state doesn’t change. This directly reflects the philosophy of computed properties.

Example: expense amount in cents, displayed as “dollars.cents”. Let’s say we’re building a console expense tracking app (we started earlier with collections, and now we’ve moved items into classes). We’ll make an Expense class that stores the amount in cents and computes the output string.

class Expense(
    val id: Int,
    amountCents: Int,
    note: String
) {
    var amountCents: Int = amountCents
        private set

    var note: String = note
        private set

    val amountLabel: String
        get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
}

Here amountLabel is a computed property: every time it’s built honestly from amountCents. No “what if we forgot to update the string after changing the amount.”

Computed property vs “stored field” — a small table:

Approach What it is Pros Cons
Stored property (with a backing field)
var x = ...
fast to read risk of “two sources of truth” if you also store derived values
Computed property
val y get() = ...
always up to date, doesn’t store extra data computed on every read (so it should be reasonably cheap)

3. Backing field and field: where the value lives

When a property has the usual form var name: String = "...", Kotlin stores the value “inside” the object. In language terms, this is called a backing field (an internal storage field). When you write a custom get() or set(value), sometimes you need to refer to that storage.

For that, Kotlin has a special word: field.

If you remember one rule from this part of the lecture, make it this: inside a setter, you must not assign to the property using its own name—you’ll get recursion. You must assign to field.

An incorrect setter (recursion) is the classic beginner trap:

class User {
    var name: String = "Unknown"
        set(value) {
            name = value.trim() // ❌ recursion: the setter calls itself
        }
}

What happens? The setter calls the setter, which calls the setter… and so on, until the call stack runs out.

The correct setter using field:

class User {
    var name: String = "Unknown"
        set(value) {
            field = value.trim() // ✅ write to the internal storage
        }
}

fun main() {
    val u = User()
    u.name = "  Alice  "
    println(u.name) // Alice
}

Here, field is the “real value of the property,” while name is “access to the property through its API.”

Where field exists—and where it doesn’t:

field exists only if the property has a backing field, i.e., Kotlin actually stores a value. A computed property usually has no storage: it’s computed “on the fly,” so field simply isn’t needed there.

4. Validation and normalization in set: maintaining invariants

When we say “invariant,” it sounds a bit academic, but the meaning is simple: the object must remain valid at all times, not only “if the class user was careful enough and didn’t touch anything extra.”

A setter is a great place to:

  1. normalize input (for example, trim() for strings),
  2. reject invalid values via require(...),
  3. enforce range constraints (for example, volume level 0..10).

A setter is like a “security guard at the entrance”: if someone tries to bring something weird into the object, the object says “no” immediately, instead of pretending everything is fine.

Normalization: a note doesn’t store extra spaces.

In Expense, we want the note to be stored neatly. But we don’t want to write .trim() all over the application code. So we should centralize normalization.

class Expense(val id: Int, amountCents: Int, note: String) {
    var note: String = note
        set(value) {
            field = value.trim()
        }
}

Validation: you can’t set a negative amount.

Now let’s add a rule: an expense amount must be greater than zero (or at least non-negative—choose what’s product-correct). A setter is a good place for this rule. But we also need to decide whether we want to allow changing the amount from the outside at all.

Let’s do it like this: it can’t be changed from the outside, but it can be changed inside the class (for example, via a method changeAmount(...)).

class Expense(val id: Int, amountCents: Int) {
    var amountCents: Int = amountCents
        private set(value) {
            require(value >= 0) { "amountCents must be >= 0" }
            field = value
        }

    fun changeAmount(newAmountCents: Int) {
        amountCents = newAmountCents
    }
}

Notice how the ideas combine nicely here: private set blocks direct external writes, but the setter still exists and protects the invariant. So even inside the class, you can’t accidentally assign garbage—the check lives in one place.

5. Example: expense tracking with safe properties

Now comes the important “how this is used in real code” moment, because otherwise getters/setters can feel like magic for the sake of magic. Let’s say our console app stores a list of expenses in memory (we already know MutableList, input/validation, and commands).

We want:

  • an expense to have an id that can’t be changed;
  • the amount to never become negative;
  • the note to be normalized (trim());
  • a convenient computed property for displaying the amount.

Expense class (minimally practical):

class Expense(
    val id: Int,
    amountCents: Int,
    note: String
) {
    var amountCents: Int = amountCents
        private set(value) {
            require(value >= 0) { "amountCents must be >= 0" }
            field = value
        }

    var note: String = note
        set(value) {
            field = value.trim()
        }

    val amountLabel: String
        get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"

    fun changeAmount(newAmountCents: Int) {
        amountCents = newAmountCents
    }
}

It’s short, but already “grown-up”: external code can’t break the object with an accidental assignment.

Storage class ExpenseBook: a computed property for the sum of all expenses.

Now let’s build an object that stores expenses. And add a computed property totalCents, so we don’t store a separate variable and don’t sync it manually after each add/remove.

class ExpenseBook {
    private val expenses: MutableList<Expense> = mutableListOf()

    val totalCents: Int
        get() = expenses.sumOf { it.amountCents }

    fun add(expense: Expense) {
        expenses.add(expense)
    }
}

totalCents here is a computed property. Yes, it’s recalculated on every read. But for a learning app, that’s fine—and most importantly, it’s logically reliable: the sum always matches the list.

Mini demo in main:

fun main() {
    val book = ExpenseBook()
    book.add(Expense(id = 1, amountCents = 199, note = " coffee "))
    book.add(Expense(id = 2, amountCents = 2500, note = "groceries"))

    println(book.totalCents) // 2699
}

6. Common mistakes when working with getters and setters

Mistake #1: recursion in a setter due to assigning “to itself.”
The most common story looks like this: you write a custom set(value), and out of habit you do prop = value. But prop = value is another setter call. As a result, you build a perpetual motion machine powered not by caffeine, but by StackOverflow. The cure is one word: inside a setter, use field = ...

Mistake #2: turning a computed property into an “expensive function” that gets read 100 times.
A computed property is good when the computation is cheap and conceptually matches a “showcase/proxy property.” If inside get() you start doing heavy data processing, long loops, or complex parsing, reading the property becomes an unexpectedly expensive operation. Kotlin style hints at this: a property is appropriate when the computation is cheap and predictable.

Mistake #3: scattering validation throughout the code instead of keeping it in one place.
Beginners often do this: check in one place, forget in another, and in a third “it’ll be fine there.” As a result, sometimes the object is valid, sometimes it’s not, and bugs take on a life of their own. It’s better to enforce invariants either in init, or in set, or in class methods—but in a way that any state change goes through the same control.

Mistake #4: making a public var and then trying to “make a deal” with the class user.
Sometimes it feels like: “I’ll just write in a comment that you can’t assign a negative value.” That works about as well as the “do not press the red button” sign in movies—people can’t resist. If a value must not be changed from the outside, close writing with private set and provide methods that apply the change safely.

Mistake #5: confusing “hide the property” with “close writes.”
private var hides the property entirely—outside, it’s as if it doesn’t exist. var ... private set leaves public reads but forbids writes. Beginners sometimes pick private var, and then wonder why they have to write a separate getBalance() and manually maintain the API. Usually it’s simpler and cleaner to expose reading as a property and close writes.

1
Task
Kotlin SELF, level 32, lesson 1
Locked
Profile name
Profile name
1
Task
Kotlin SELF, level 32, lesson 1
Locked
Attempt Counter
Attempt Counter
1
Task
Kotlin SELF, level 32, lesson 1
Locked
Live Rectangle
Live Rectangle
1
Task
Kotlin SELF, level 32, lesson 1
Locked
Thermometer validation
Thermometer validation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION