Mutator
KSP-generated mutable wrappers for immutable data classes. Inspired by immer.js.
Immutability is worth it
Immutable data structures eliminate an entire class of bugs — no shared mutable state, no defensive copying,
no wondering whether some other part of the code changed your object while you weren't looking.
Kotlin's val and data class make this the natural default, and that's a good thing.
The problem: nested copy() chains
But updating a deeply nested immutable field is painful:
@Mutable
data class Company(val name: String, val ceo: Person)
@Mutable
data class Person(val firstName: String, val lastName: String, val address: Address)
@Mutable
data class Address(val street: String, val city: String, val zip: String)
// Create a company
val company = Company(
name = "ACME",
ceo = Person(
firstName = "Jane",
lastName = "Doe",
address = Address(street = "123 Main St", city = "Springfield", zip = "62701")
)
)
// Now change the CEO's street. With copy():
val updated = company.copy(
ceo = company.ceo.copy(
address = company.ceo.address.copy(
street = "456 Oak Ave"
)
)
) That's 5 lines of nesting to change one leaf field. Now imagine a form with 20 fields across 4 levels of nesting. Every field needs its own copy() chain. Every chain must be wired through callbacks to propagate changes back up. It's tedious, error-prone, and the noise completely obscures the intent.
The Mutator way
// Same update with Mutator:
val updated = company.mutate {
ceo {
address {
street = "456 Oak Ave"
}
}
} One block. Mutator handles all the copy() calls under the hood. The result is a new immutable instance — your original data is never touched.
When you DON'T need Mutator
If your data classes are flat or shallow, you don't need this library.
A single copy() call is perfectly fine:
// For flat data classes, copy() is great. Don't add Mutator for this.
val updated = address.copy(street = "456 Oak Ave") Mutator's value is proportional to nesting depth. The deeper your data structures, the more copy() chains you avoid, and the more callback wiring Mutator eliminates. The sweet spot is 2+ levels of nesting, especially when combined with collections.
What you get
Sub-Mutators
Every property of a mutator is itself a mutator. Changes propagate
automatically from leaf to root via onChange.
Dirty Tracking
isModified() tells you if anything changed.
commit() accepts the current state as the new baseline.
Collection Mutators
ListMutator, SetMutator, MapMutator
— mutate elements in place. Add, remove, iterate and modify.
Sealed Class Support
Mutate polymorphic hierarchies with cast() and filterMutatorsOf<T>().
KSP Code Generation
Annotate with @Mutable, and KSP generates
.mutator(), .mutate {}, and typed property accessors at compile time.
Structural Equality
Setting a field to its current value is a no-op. Reverting all changes returns the exact same instance (identity equality).
Quick taste
val person = Person(
firstName = "John",
lastName = "Doe",
age = 42,
address = Address(street = "Main St", city = "Springfield", zip = "62701")
)
// One-shot mutation — returns a new immutable Person
val updated = person.mutate {
firstName = "Jane"
address {
city = "Shelbyville"
}
}
// updated == Person("Jane", "Doe", 42, Address("Main St", "Shelbyville", "62701"))
// Long-lived mutator — for forms, editors, stateful UIs
val mutator = person.mutator()
mutator.address.street = "Oak Ave"
mutator.isModified() // true
mutator() // Person with street = "Oak Ave"
mutator.commit() // Accept changes as new baseline
mutator.isModified() // false