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.