Core Concepts

Mutators, sub-mutators, change propagation, dirty tracking, and structural equality.

The Mutator interface

At its core, a Mutator<V> wraps an immutable value and provides a mutable API over it:

interface Mutator<V> {
    fun get(): V              // Read the current value
    fun set(input: V): V     // Replace the entire value
    operator fun invoke(): V  // Shorthand for get()

    fun isModified(): Boolean // Has the value changed since creation / last commit?
    fun getInitialValue(): V  // The value at creation / last commit
    fun commit()              // Accept current value as new baseline
    fun reset(): V            // Revert to initial value

    fun modifyValue(block: (V) -> V)  // Transform the value
}

You rarely interact with this interface directly. KSP generates typed extensions that let you write mutator.street = "..." instead of mutator.modifyValue { it.copy(street = "...") }.

Two ways to mutate

One-shot: mutate { }

Creates a mutator, applies your block, and returns the new immutable value. Use this when you just need the result:

val updated = address.mutate {
    street = "456 Oak Ave"
    city = "Shelbyville"
}
// updated is a new Address; the original is untouched

Long-lived: mutator()

Creates a mutator you keep around. Use this for forms, editors, or any UI that accumulates changes over time:

val mutator = address.mutator()

// Later, on user input:
mutator.street = "456 Oak Ave"

// Read the current state at any time:
val current = mutator()

// Check if anything changed:
mutator.isModified()  // true

Sub-mutators and onChange propagation

This is the key insight that makes Mutator powerful. When a @Mutable data class contains another @Mutable data class, KSP generates a sub-mutator for that property.

val person = Person(
    firstName = "John",
    lastName = "Doe",
    age = 42,
    address = Address(street = "Main St", city = "Springfield", zip = "62701")
)

val mutator = person.mutator()

// mutator.address is a sub-mutator for Address.
// When you modify it, the change automatically propagates to the Person:
mutator.address.street = "Oak Ave"

// The person mutator now reflects the change:
mutator().address.street  // "Oak Ave"
mutator.isModified()      // true

Under the hood, the generated code wires onChange callbacks from child to parent:

// KSP generates this for Person.address:
val Mutator<Person>.address
    get() = get().address.mutator()
        .onChange { address -> modifyValue { get().copy(address = address) } }

// When you set mutator.address.street = "Oak Ave":
// 1. The Address mutator calls copy(street = "Oak Ave")
// 2. This triggers the Address mutator's onChange
// 3. The Person mutator receives the new Address
// 4. The Person mutator calls copy(address = newAddress)
// 5. The Person mutator's onChange fires (if anyone is listening)

This chain works at any depth. A 5-level deep mutation propagates all the way to the root — you never write a single copy() call.

Observing changes with onChange

You can observe a mutator to react to any change:

val mutator = person.mutator()

mutator.onChange { newPerson ->
    println("Person changed: $newPerson")
}

mutator.address.street = "Oak Ave"
// prints: "Person changed: Person(firstName=John, ..., address=Address(street=Oak Ave, ...))"

mutator.firstName = "Jane"
// prints: "Person changed: Person(firstName=Jane, ...)"

// Setting to the same value does NOT trigger onChange:
mutator.firstName = "Jane"
// (nothing printed)

Dirty tracking: isModified() and commit()

isModified() compares the current value to the initial value (or the last committed value). This is perfect for form "save" buttons and "unsaved changes" warnings.

val mutator = address.mutator()

mutator.isModified()  // false — nothing changed yet

mutator.street = "Oak Ave"
mutator.isModified()  // true — street differs from initial

mutator.street = "Main St"  // revert to original
mutator.isModified()  // false — back to initial state!

mutator.street = "Oak Ave"
mutator.isModified()  // true

mutator.commit()      // Accept "Oak Ave" as the new baseline
mutator.isModified()  // false
mutator.getInitialValue().street  // "Oak Ave"

Structural equality guarantees

Mutator is careful about identity. If a mutation results in a value equal to the initial value, Mutator returns the exact same instance (reference equality):

val address = Address(street = "Main St", city = "Springfield", zip = "62701")

// Empty mutation — returns the exact same object
val result1 = address.mutate { }
result1 === address  // true (same reference!)

// Set to same value — still the same object
val result2 = address.mutate { street = "Main St" }
result2 === address  // true

// Change and revert — still the same object
val result3 = address.mutate {
    street = "Oak Ave"
    street = "Main St"
}
result3 === address  // true

This matters for UI frameworks that use reference equality to skip re-renders. If nothing actually changed, you get the same object back, and downstream equality checks short-circuit immediately.

The modifyIfChanged optimization

Generated property setters use modifyIfChanged to avoid unnecessary work:

// Generated code:
var Mutator<Address>.street
    get() = get().street
    set(v) = modifyIfChanged(get().street, v) { it.copy(street = v) }

// modifyIfChanged only calls the copy() lambda when previous != next.
// This means:
//   mutator.street = "Main St"  // (same value — no copy, no notification)
//   mutator.street = "Oak Ave"  // (different — copies and notifies)

This is important for performance: setting a property to its current value is literally free — no allocation, no notification, no propagation.