State handling using immutable objects

I tried to use arrow-optics for an experimental hobby project but I don’t like it as much as I expected.
So I created my own prototype for mutating immutable objects.

The core types are:

interface ImmutableObject<out MutableType> {
    fun toMutable(): MutableType
}

interface MutableObject<out ImmutableType> {
    fun toImmutable(): ImmutableType
}

First the domain model should be defined by so called “definition interfaces”:

@Immutate
interface TodoAppState {
    val currentScreen: Screen
    val todoLists: List<TodoList>
}

@Immutate
sealed interface Screen

@Immutate
interface ShowTodoListsScreen : Screen {
    val nameFilter: String
}

@Immutate
interface EditTodoListScreen : Screen {
    val editedIndex: Int
    val editedTodoList: TodoList
}

@Immutate
interface TodoList {
    val name: String
    val items: List<TodoItem>
}

@Immutate
interface TodoItem {
    val text: String
    val completed: Boolean
}

A KSP plugin is used to automatically generate immutable and mutable variations of these interfaces.
This is highly experimental but for example the following code is generated from the TodoList interface:

public interface TodoListMutable : TodoList,
    MutableObjectImplementor<TodoListMutable, TodoListImmutable> {
  public override var name: String

  public override val items: ConfinedMutableList<TodoItemMutable, TodoItemImmutable>
}

public data class TodoListImmutable(
  public override val name: String,
  public override val items: ImmutableList<TodoItemImmutable>
) : TodoList, ImmutableObject<TodoListMutable> {
  public override fun toMutable(): TodoListMutable = todoapp.TodoListMutableImpl(this)

  public override fun equals(other: Any?): Boolean {
                    if (this === other) return true
                    if (other == null || other !is todoapp.TodoList) {
                        return false 
                    }
                    
                    if (name != other.name) { return false }
    if (items != other.items) { return false }
                                            
                    return true
  }

  public override fun hashCode(): Int {
    var result = name?.hashCode() ?: 0
    result = 31 * result + (items?.hashCode() ?: 0)
    return result
  }

  public override fun toString(): String = """TodoList(name=$name, items=$items)"""
}

public class TodoListMutableImpl(
  private val source: TodoListImmutable
) : TodoListMutable {
  private val _objectState: MutableObjectState<TodoListMutable, TodoListImmutable> =
      MutableObjectState(
          source,
          todoapp.TodoListMutableImpl.properties
      )

  public override val _isModified: Boolean
    get() = _objectState.isModified

  public override var name: String by mutableValueProperty(_objectState)

  public override val items: ConfinedMutableList<TodoItemMutable, TodoItemImmutable> by
      mutableListProperty(_objectState, source.items)

  public override fun toImmutable(): TodoListImmutable = if (_isModified) {
   todoapp.TodoListImmutable(name, items.toImmutable())
  } else {
      source
  }

  public override fun equals(other: Any?): Boolean {
                    if (this === other) return true
                    if (other == null || other !is todoapp.TodoList) {
                        return false 
                    }
                    
                    if (name != other.name) { return false }
    if (items != other.items) { return false }
                                            
                    return true
  }

  public override fun hashCode(): Int {
    var result = name?.hashCode() ?: 0
    result = 31 * result + (items?.hashCode() ?: 0)
    return result
  }

  public override fun toString(): String = """TodoList(name=$name, items=$items)"""

  public companion object {
    public val properties: Map<String, KProperty1<TodoListImmutable, *>> = listOf(
                TodoListImmutable::name, TodoListImmutable::items
        ).associateBy { it.name }

  }
}

As you can see a TodoListImmutable data class is generated for storing immutable state, and a TodoListMutable interface (with a non-public implementation) for mutating state.
These types can be converted from and to each other. The conversion is efficient, so for example calling immutableObject.toMutable().toImmutable() returns the original immutableObject instance.
As you can see the mutable TodoListMutable interface allows direct modification of simple fields and comfortable modification of lists. The latter is handled by ConfinedMutableList, a list implementation extending MutableList, so the modification is fairly intuitive.

After the domain model is defined and generated it is optionally possible to define utility functions to mutate the state:

fun TodoAppStateMutable.openEditScreen(editedIndex: Int, editedTodoList: TodoList) {
    currentScreen = EditTodoListScreenImmutable(editedIndex, editedTodoList).toMutable()
}

fun TodoAppStateMutable.openHomeScreen() {
    currentScreen = ShowTodoListsScreenImmutable("").toMutable()
}

For this example I implemented a simple Compose/Desktop example application using the domain model above. Please note the mutate() and mutateApplicationState() calls where the immutable state is “mutated”:

private val stateFlow = MutableStateFlow(
    TodoAppStateImmutable(ShowTodoListsScreenImmutable(""), persistentListOf())
)

fun mutateApplicationState(mutator: (TodoAppStateMutable) -> Unit) =
    stateFlow.update {
        it.mutate(mutator)
    }

val stateProvider = compositionLocalOf { stateFlow.value }

fun main() {
    application {
        val state by stateFlow.collectAsState()
        CompositionLocalProvider(stateProvider provides state) {
            Window(
                onCloseRequest = { exitApplication() }
            ) {
                MaterialTheme {
                    TodoApp(stateProvider.current)
                }
            }
        }
    }
}

@Composable
fun TodoApp(state: TodoAppState) {
    when (val currentScreen = state.currentScreen) {
        is ShowTodoListsScreen -> ShowTodoLists(state.todoLists, currentScreen.nameFilter)
        is EditTodoListScreen -> TodoListEditor(currentScreen.editedTodoList)
    }
}

@Composable
fun TodoListEditor(editedTodoList: TodoList) {
    var editedState by remember { mutableStateOf(editedTodoList.toImmutable()) }
    Scaffold(
        floatingActionButton = {
            Button(
                onClick = {
                    editedState = editedState.mutate {
                        it.items.add(TodoItemImmutable("New TODO item.", false))
                    }
                }
            ) {
                Text("+")
            }
        }
    ) {
        Column(modifier = Modifier.fillMaxSize()) {
            Row(
                horizontalArrangement = Arrangement.SpaceBetween,
                modifier = Modifier.fillMaxWidth()
            ) {
                TextField(
                    value = editedState.name,
                    onValueChange = { fieldValue ->
                        editedState = editedState.mutate {
                            it.name = fieldValue
                        }
                    }
                )
                Row {
                    Button(
                        modifier = Modifier.padding(5.dp),
                        onClick = {
                            mutateApplicationState {
                                val editedTodoListIndex = (it.currentScreen as EditTodoListScreen).editedIndex
                                it.todoLists[editedTodoListIndex] = editedState.toMutable()

                                it.openHomeScreen()
                            }
                        }
                    ) {
                        Text("Save")
                    }
                    Button(
                        modifier = Modifier.padding(5.dp),
                        onClick = {
                            mutateApplicationState {
                                it.openHomeScreen()
                            }
                        }
                    ) {
                        Text("Cancel")
                    }
                }
            }
            Divider(modifier = Modifier.padding(vertical = 10.dp))
            editedState.items.forEachIndexed { index, todoItem ->
                Row(modifier = Modifier.fillMaxWidth()) {
                    Checkbox(
                        checked = todoItem.completed,
                        onCheckedChange = {
                            editedState = editedState.mutate {
                                it.items[index].completed = !it.items[index].completed
                            }
                        }
                    )
                    TextField(
                        value = todoItem.text,
                        onValueChange = { fieldValue ->
                            editedState = editedState.mutate {
                                it.items[index].text = fieldValue
                            }
                        }
                    )
                }
            }
        }
    }
}

@Composable
fun ShowTodoLists(todoLists: List<TodoList>, nameFilter: String) {
    Scaffold(
        floatingActionButton = {
            Button(
                onClick = {
                    mutateApplicationState {
                        it.todoLists.add(TodoListImmutable("New TODO list", persistentListOf()))
                    }
                }
            ) {
                Text("+")
            }
        }
    ) {
        Column(modifier = Modifier.fillMaxSize()) {
            Row(modifier = Modifier.fillMaxWidth()) {
                TextField(
                    value = nameFilter,
                    onValueChange = { fieldValue ->
                        mutateApplicationState {
                            (it.currentScreen as ShowTodoListsScreenMutable).nameFilter = fieldValue
                        }
                    }
                )
            }
            Divider(modifier = Modifier.padding(vertical = 10.dp))
            todoLists
                .filter { it.name.lowercase().contains(nameFilter.lowercase()) }
                .forEachIndexed { index, todoList ->
                    Row(modifier = Modifier.fillMaxWidth()) {
                        ClickableText(
                            text = AnnotatedString(
                                if (todoList.items.isEmpty())
                                    "${todoList.name} (empty)"
                                else if (todoList.items.all { it.completed })
                                    "${todoList.name}  (all ${todoList.items.size} completed)"
                                else
                                    "${todoList.name} (${todoList.items.count { !it.completed }} of ${todoList.items.size} to be completed."
                            ),
                            style = TextStyle(fontSize = 30.sp),
                            onClick = {
                                mutateApplicationState {
                                    it.openEditScreen(index, todoList)
                                }
                            }
                        )
                    }
                }
        }
    }
}

Unfortunatelly this is only a hobby project created on some of the last weekends and I’m new to KSP, so the KSP plugin is not at a quality level that can be shared.
But I’m very interested in your opinion about the idea.
Thanks.

1 Like