Collection Mutators
ListMutator, SetMutator, and MapMutator let you
add, remove, and modify elements in immutable collections — with full change propagation.
The problem
Mutating a list of nested data classes with pure copy() is where things get truly ugly:
@Mutable
data class AddressBook(val addresses: List<Address>)
val book = AddressBook(
addresses = listOf(
Address("Main St", "Springfield", "62701"),
Address("Oak Ave", "Shelbyville", "62702"),
)
)
// Change the city of the SECOND address using copy():
val updated = book.copy(
addresses = book.addresses.mapIndexed { index, addr ->
if (index == 1) addr.copy(city = "Capital City")
else addr
}
)
// That's 5 lines for one field change. In a list. Of nested objects. With Mutator:
val updated = book.mutate {
addresses[1].city = "Capital City"
} ListMutator
KSP generates List<T>.mutator() and List<T>.mutate { } for every @Mutable
class. The ListMutator implements MutableList<Mutator<T>>, so you can use
all familiar list operations.
Iterating and modifying elements
val addresses = listOf(
Address("Street 1", "City 1", "Zip 1"),
Address("Street 2", "City 2", "Zip 2"),
)
val updated = addresses.mutate {
forEach { it.street += " (verified)" }
}
// Result: [Address("Street 1 (verified)", ...), Address("Street 2 (verified)", ...)] Indexed access
val updated = addresses.mutate {
this[0].city = "New City"
this[1].street = "New Street"
} Adding and removing
val mutator = addresses.mutator()
// Add an element (use the add() extension)
mutator.add(Address("Street 3", "City 3", "Zip 3"))
// Remove by index
mutator.removeAt(0)
// Clear the list
mutator.clear()
// Check the result
mutator() // [Address("Street 3", "City 3", "Zip 3")] Nested list in a data class
When a @Mutable data class has a List property, the sub-mutator is a
ListMutator with full propagation:
val book = AddressBook(
addresses = listOf(
Address("Main St", "Springfield", "62701"),
Address("Oak Ave", "Shelbyville", "62702"),
)
)
val updated = book.mutate {
// Modify an existing element
addresses[0].city = "Capital City"
// Add a new address
addresses.add(Address("Elm St", "Portland", "97201"))
}
// The AddressBook is rebuilt with the updated list — all immutable SetMutator
SetMutator works similarly, implementing MutableSet<Mutator<T>>.
@Mutable
data class WithCollections(
val tags: Set<Address>,
val lookup: Map<String, Address>,
)
val obj = WithCollections(
tags = setOf(
Address("Street 1", "City 1", "Zip 1"),
Address("Street 2", "City 2", "Zip 2"),
),
lookup = emptyMap(),
)
// Modify elements via iteration
val updated = obj.mutate {
tags.forEach { mutator ->
if (mutator().street == "Street 1") {
mutator.street = "Modified Street"
}
}
}
// Add to a set
val added = obj.mutate {
tags.add(Address("Street 3", "City 3", "Zip 3"))
}
// Clear a set
val cleared = obj.mutate {
tags.clear()
} MapMutator
MapMutator implements MutableMap<K, Mutator<V>>. Keys are immutable; values
are mutators.
val obj = WithCollections(
tags = emptySet(),
lookup = mapOf(
"home" to Address("Main St", "Springfield", "62701"),
"work" to Address("Office Rd", "Capital City", "62702"),
),
)
// Modify a value by key
val updated = obj.mutate {
lookup["home"]?.street = "New Main St"
}
// Modify via entries
val updated2 = obj.mutate {
lookup.entries.forEach { entry ->
if (entry.key == "work") {
entry.value.city = "Metro City"
}
}
}
// Remove an entry
val updated3 = obj.mutate {
lookup.remove("work")
}
// Clear the map
val updated4 = obj.mutate {
lookup.clear()
} How collection propagation works
When you access an element from a ListMutator (via index, forEach, or iterator), you
get a Mutator<T> with an onChange callback wired to the list. Modifying any
property of that element triggers:
- The element's
copy()— producing a new immutable element - The element's
onChange— which updates the element in the list - The list's
onChange— which propagates to the parent data class - And so on up the chain
This means addresses[0].city = "X" inside a book.mutate { } block triggers a
cascade that rebuilds the entire AddressBook with the updated list — all automatically.