Question

Custom Getters in Kotlin Data Classes

Question

Given this Kotlin data class:

data class Test(val value: Int)

How can the value getter return 0 when the stored value is negative? If overriding the getter directly is not possible, what Kotlin techniques can provide an appropriate result?

Short Answer

You will learn why a primary-constructor property in a Kotlin data class cannot declare a custom getter, how to expose a calculated property with a private backing value, and when it is better to normalize or reject invalid input instead.

Concept

A Kotlin property can have a custom getter:

val value: Int
    get() = 0

However, a property declared directly in a data class's primary constructor has a compact form:

data class Test(val value: Int)

That syntax creates both a constructor parameter and a property, but it does not provide a place to write a custom accessor. You also cannot declare another property named value in the class body.

Additionally, override is not the right keyword here. override is used only when a class replaces an open or abstract property inherited from a superclass or interface. A custom getter changes the behavior of a property declared by the same class.

The important design decision is whether negative numbers should be:

  • Stored but hidden from callers: retain the original value internally and expose 0 through a computed getter.
  • Normalized when the object is created: store 0 instead of every negative value.
  • Rejected: fail early because a negative value is invalid for the domain.

This choice affects equality, copy(), destructuring, serialization, and debugging.

Mental Model

Think of a property as a service desk.

  • A normal stored property gives visitors the item from a box directly.
  • A custom getter lets the desk inspect the item before handing it over.
  • A backing property is the box in the staff-only room.

If the box contains -5, the service desk can hand out 0. But the original box still contains -5. Alternatively, you can replace -5 with 0 before putting anything in the box. These two approaches look similar to a caller reading value, but they have different behavior elsewhere.

Take Quiz

Syntax and Examples

A custom getter is declared in a property body:

val displayedValue: Int
    get() = rawValue.coerceAtLeast(0)

For a data class, put the stored value in the primary constructor and expose a separate computed property:

data class Test(private val rawValue: Int) {
    val value: Int
        get() = rawValue.coerceAtLeast(0)
}

fun main() {
    val test = Test(-8)

    println(test.value) // 0
}

coerceAtLeast(0) returns the number itself when it is at least 0; otherwise, it returns 0.

An equivalent if expression is:

val value: Int
    get() = if (rawValue < 0) 0 else rawValue

Use coerceAtLeast when the rule is simply “never expose a value below this minimum.”

Step by Step Execution

Consider this code:

data class Test(private val rawValue: Int) {
    val value: Int
        get() = rawValue.coerceAtLeast(0)
}

fun main() {
    val first = Test(12)
    val second = Test(-4)

    println(first.value)
    println(second.value)
}

Execution steps:

  1. Test(12) stores 12 in first.rawValue.
  2. Reading first.value runs its getter.
  3. 12.coerceAtLeast(0) evaluates to 12, so the first line prints 12.
  4. Test(-4) stores -4 in second.rawValue.
  5. Reading second.value runs the same getter.
  6. (-4).coerceAtLeast(0) evaluates to 0, so the second line prints 0.

The getter runs every time value is read. It does not change rawValue.

Real World Use Cases

Custom or computed getters are useful when an application needs a safe value for display or calculations:

  • Progress indicators: show at least 0% even if an intermediate calculation becomes negative.
  • Inventory displays: prevent a UI from displaying a negative available quantity while an adjustment is pending.
  • Pricing previews: expose a non-negative charge after applying discounts.
  • API response models: provide a derived, client-friendly field from a raw imported value.
  • Compatibility layers: keep legacy raw data but present a corrected interpretation to newer code.

For business-critical rules, consider whether hiding invalid data is appropriate. A negative stock count may be valuable information that should be fixed rather than silently displayed as zero.

Take Quiz

Real Codebase Usage

In real Kotlin projects, developers commonly choose one of these patterns.

1. Normalize at construction time

Use this when negative values have no meaning anywhere in the program:

data class Test(val value: Int) {
    init {
        require(value >= 0) { "value must not be negative" }
    }
}

Or, when clamping is the intended rule:

data class Test private constructor(val value: Int) {
    companion object {
        operator fun invoke(value: Int): Test {
            return Test(value.coerceAtLeast(0))
        }
    }
}

This keeps data-class equality, copy(), and destructuring aligned with the public value.

2. Keep raw data and expose a derived value

Use this when the original value matters for diagnostics, auditing, or a separate calculation:

data class Test(private val rawValue: Int) {
    val value: Int get() = rawValue.coerceAtLeast(0)
}

3. Use a named derived property

Often the clearest approach is to avoid calling two different concepts value:

data class Test(val rawValue: Int) {
    val nonNegativeValue: Int
        get() = rawValue.coerceAtLeast(0)
}

Clear names make it obvious whether code is using original input or a safe display value.

Common Mistakes

Trying to add a getter after a primary-constructor property

This is not valid Kotlin:

// Does not compile
data class Test(val value: Int) {
    get() = value.coerceAtLeast(0)
}

A getter must belong to a property declaration, and the compact primary-constructor property syntax cannot include an accessor body.

Redeclaring the same property name

This also does not compile:

// Does not compile
data class Test(val value: Int) {
    val value: Int
        get() = 0
}

The class would contain two properties named value.

Creating infinite recursion in a getter

This compiles but crashes with a stack overflow when read:

class Test(private val rawValue: Int) {
    val value: Int
        get() = value.coerceAtLeast(0) // Reads value again and again
}

Read the backing property instead:

val value: Int
    get() = rawValue.coerceAtLeast(0)

Assuming data-class equality uses computed body properties

data class Test(private val rawValue: Int) {
    val value: Int get() = rawValue.coerceAtLeast(0)
}

println(Test(-1) == Test(-2)) // false

Both objects expose value == 0, but the data class compares its primary-constructor property, rawValue. Normalize the stored constructor value if equality should treat both as the same.

Using override without inheritance

A getter is not “overridden” unless it replaces an inherited open property. For a property declared in the same class, write a custom getter without override.

Comparisons

ApproachWhat is stored?What callers read?Best when
Custom getter with rawValueOriginal value, including negatives0 for negativesRaw input must be retained
Clamp on constructionOnly non-negative valueStored valueNegative values should never exist in the model
require(value >= 0)Only valid valuesStored valueNegative input is a programming or validation error
Named derived propertyOriginal valueA separate safe valueBoth raw and adjusted meanings are important

A data class only includes properties declared with val or var in its primary constructor in generated equals(), hashCode(), copy(), toString(), and componentN() functions. Properties declared in the class body are not included.

Take Quiz

Cheat Sheet

// A regular custom getter
val property: Int
    get() = expression
// Data class with a private stored value and public computed value
data class Test(private val rawValue: Int) {
    val value: Int
        get() = rawValue.coerceAtLeast(0)
}
// Keep the data class value canonical by clamping before storage
data class Test private constructor(val value: Int) {
    companion object {
        operator fun invoke(value: Int) = Test(value.coerceAtLeast(0))
    }
}
// Reject invalid input
data class Test(val value: Int) {
    init {
        require(value >= 0)
    }
}

Rules:

  • override requires an inherited open or abstract property.
  • Do not read the same property inside its own getter.
  • Primary-constructor data-class properties determine generated equality and copy() behavior.
  • coerceAtLeast(0) is a concise non-negative clamp.

FAQ

Can a Kotlin data class primary-constructor property have a custom getter?

Not with the compact data class Test(val value: Int) syntax. Use a separate backing property and a body property, or normalize the constructor input before storing it.

Does coerceAtLeast(0) change the original integer?

No. Int values are immutable. The function returns a new result: either the original number or 0.

Should I clamp a negative value or throw an exception?

Clamp when negative input is expected and 0 is the defined fallback. Use require when a negative value indicates invalid program state or invalid input that must be corrected.

Will copy() use a computed property in a data class?

No. copy() uses primary-constructor properties. With private val rawValue, copy() copies rawValue, not the computed value property.

Why are Test(-1) and Test(-2) unequal when both return zero?

When the data class stores rawValue, equality compares rawValue. Normalize before storage if both inputs should be considered equivalent.

Can an extension property replace an existing getter?

No. Extension properties cannot override or replace member properties. They also cannot have backing fields.

Is maxOf(value, 0) equivalent to value.coerceAtLeast(0)?

For this integer rule, yes. coerceAtLeast(0) communicates the intent more directly.

Take Quiz

Mini Project

Description

Build a small score model for a quiz application. Imported score adjustments may be negative, but the displayed score must never be below zero. The model should retain the raw adjustment so a developer can inspect it later.

Goal

Create a Kotlin data class that exposes a non-negative score while preserving the original raw score.

Requirements

Define a data class with a private primary-constructor property named rawScore. Add a public read-only property named displayScore. Make displayScore return 0 when rawScore is negative. Create one positive and one negative instance. Print each instance's raw representation and displayed score.

Take Quiz

Keep learning

Related questions

View more