Immutability is a good practice with a lot of advantages. One of the disadvantages however is that it is hard to make changes in deeply nested immutable data structures. To circumvent this, Optics were invented and the Arrow library brings these to Kotlin.

Let’s investigate the problem first. We can have a simple nested data structure:

data class Person(val name: String, val city: City)
data class City(val name: String, val street: Street)
data class Street(val name: String)

If we want to change the name of a Person we can just use the copy method.

val person = Person("MyName", City("MyCity", Street("MyStreet")))
val newPerson = person.copy(name = "MyNewName")

However, if we want to change the street name we start to see the downsides:

val updatedStreetPerson = person.copy(
  city = person.city.copy(
    street = person.city.street.copy(name = "MyNewStreet")
  )
)

Let’s see how Optics can help us.

Optics

Optics are the functional way to make changes to deeply nested immutable data structures. At the core of this is the Lens. A Lens is an object made to zoom in on a specific property of another object. For example, we can make a Lens for the city property of Person. For this we need to define a way to get the property and to set the property.

val cityLens: Lens<Person, City> = Lens(
  get = { it.city },
  set = { person: Person, city: City -> person.copy(city = city) }
)

This is a Lens that takes a Person object and it can either get or modify the city property.

val person = Person("MyName", City("MyCity", Street("MyStreet")))

val city: City = cityLens.get(person) //Get City property

val modifyCity = cityLens.modify(person) { //Modify City property
  c: City -> c.copy(name = "MyNewCity")
}

Having one lens does not add a lot of value, the power comes when we create lenses for more properties

//City -> Street
val streetLens: Lens<City, Street> = Lens(
  get = { it.street },
  set = { city: City, street: Street -> city.copy(street = street) }
)
//Street -> name
val streetNameLens: Lens<Street, String> = Lens(
  get = { it.name },
  set = { street: Street, name: String -> street.copy(name = name) }
)

So now we have a Lens from Person to City, from City to Street and from Street to name. And now we can compose these lenses to create a more powerful lens that goes from Person to the Street.name property. With this lens we can now change the street name property of a person without having to do all those copy actions.

val personStreetNameLens = cityLens
  .compose(streetLens)
  .compose(streetNameLens)

val updatedStreetPerson = personStreetNameLens.modify(person){ "MyNewStreet" }

Generating lenses

Creating all these lenses is quite a bit of work. Luckily we can add a Gradle plugin to do this for us. After we add this plugin we can just add the @optics annotation and a companion object to our objects.

@optics data class Person(val name: String, val city: City) {companion object}
@optics data class City(val name: String, val street: Street) {companion object}
@optics data class Street(val name: String) {companion object}

And now Arrow will generate a lens for every property and put these in the companion object. Because these are in the companion object we can access and compose them like static properties. So after compiling we now have lenses and we can change the street name like this:

val person = Person("MyName", City("MyCity", Street("MyStreet")))
val updatedStreetPerson = Person.city.street.name.modify(person){"MyNewStreet"}

Troubleshooting

Some combinations of Kotlin and optics plugin seem not to work correctly at the time of writing. In some combinations I got a Could not find plugin error, this seemed to be fixed by upgrading to 1.0.3-alpha. This blog was written using the following versions:

  • Gradle 7.1

  • kotlin 1.6.10

  • arrow-optics 1.0.3-alpha.38

  • arrow-optics-ksp-plugin 1.0.3-alpha.38

  • com.google.devtools.ksp 1.6.20-1.0.5

It is also possible that the ksp generated files (containing the generated lenses) are not picked up. To include these in your source sets you can add this to your Gradle file:

kotlin {
    sourceSets.main {
        kotlin.srcDir("build/generated/ksp/main/kotlin")
    }
}
shadow-left