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:

  1. The element's copy() — producing a new immutable element
  2. The element's onChange — which updates the element in the list
  3. The list's onChange — which propagates to the parent data class
  4. 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.