article banner

A birds-eye view of Arrow: Data Immutability with Arrow Optics

This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon. It is also available as a course.

This part was written by Alejandro Serrano Mena, with support from Simon Vergauwen and Raúl Raja Martínez.

When working on a functional programming-inspired codebase, you often want to limit the number of side effects a function can perform. Of these, mutability is one of the main offenders: a function that depends on a mutable variable may potentially change its behavior between two runs, even if the arguments provided are exactly the same between these two runs. Making this rule more concrete in Kotlin leads to a style which:

  • Prefers val over var, even to the point of forbidding var entirely.
  • Models the application domain using data classes without methods, instead of using object-oriented techniques in which classes hold both data and behavior.

Here's one example of how persons and addresses are modeled in this fashion:

data class Address( val zipcode: String, val country: String ) data class Person( val name: String, val age: Int, val address: Address )

In fact, the design of data classes in Kotlin complements functional programming very well, thus making it much easier to err on the side of immutability. When using data classes, constructors and fields are defined in one go; no boilerplate is required, as in Java2. Another prime example of this is the copy method, which allows us to create a new version of a value based on another one, where we only change a few of the fields.

fun Person.happyBirthday(): Person = copy(age = age + 1)

This nice syntax falls short, however, when the transformation affects nested fields. For example, let's say we want to normalize the way countries are spelled out within Person. The code is by no means pretty.

fun Person.normalizeCountry(): Person = copy( address = address .copy(country = address.country.capitalize()) )

Arrow Optics provides a solution to this problem as part of the more general problem of transforming immutable data with nice syntax. Two libraries working together give Arrow Optics its power: there's the basic io.arrow-kt:arrow-optics library, and there’s also the io.arrow-kt:arrow-optics-ksp-plugin compiler plug-in, which automates some of the boilerplate required by the former. The plug-in is built using the Kotlin Symbol Processing API (KSP)3. Once the plug-in is ready, you only need to sprinkle some @optics annotations4 in your code to let the fun begin.

@optics data class Address( val zipcode: String, val country: String ) { companion object } @optics data class Person( val name: String, val age: Int, val address: Address ) { companion object }

Under the hood, the compiler plug-in generates lenses, which are a particular kind of optics. A lens is nothing more than a combination of a getter and a setter; however,in contrast to them, you use the name of the field before the element to be queried or modified. These lenses are generated as part of the companion object of the class the @optics annotation is applied to, so you can find them under the class name. The code below shows an implementation of happyBirthday using lenses.

fun Person.happyBirthday(): Person { val currentAge = Person.age.get(this) return Person.age.set(this, currentAge + 1) }

Note that the set function, regardless of its name, works as a copy method for a particular field: it generates a new version of the given value. This simplest use of lenses already brings some benefits. For example, the pattern for setting a new value of a field based on the previous value (as we are doing here for age) has been abstracted into the modify method. Kotlin's syntax for trailing lambdas allows for a very concise and readable implementation of happyBirthday in a single line.

fun Person.happyBirthday(): Person = Person.age.modify(this) { it + 1 }

Let's go back to our original problem of modifying nested fields in immutable objects without dying under a pile of copy methods. The trick is to compose lenses to create a new lens that focuses on the nested element. The setter (or the modifier) in this new lens changes exactly what we need and takes care of keeping the rest of the fields unchanged.

fun Person.normalizeCountry(): Person = (Person.address compose Address.country).modify(this) { it.capitalize() }

Accessing nested fields is such a common operation that the Arrow Optics developers have also decided to generate additional declarations to simplify this scenario. In particular, starting with an initial lens, you can compose automatically with a lens in the nested type by using a dot, as you would do with actual fields. This means you can write the preceding example as follows:

fun Person.normalizeCountry(): Person = (Person.address.country).modify(this) { it.capitalize() }

Optics are a big family whose ultimate goal is to make immutable data transformation easier. Up to this point, we’ve talked about lenses, which focus just on a single field, but the other important member of this family is traversals. Traversals make it possible to apply a transformation over several elements at once, so they are very useful for manipulating collections. As a concrete example, let’s define a new data class which holds information about every person born on a single day; this could be interesting if we’re sending a promotional code to people to celebrate their birthdays.

@optics data class BirthdayResult( val day: LocalDate, val people: List<Person> ) { companion object }

How do we change the age field for all of them? We not only need nested copy methods; we must also be careful that people is a list, so transformation occurs using map.

fun BirthdayResult.happyBirthday(): BirthdayResult = copy(people = people.map { it.copy(age = it.age + 1) })

The same transformation can be defined by composing several optics and then applying a single modify function, as before. The traversal required for this job lives in the Every class, which includes optics for the most commonly used collection types in Kotlin.

fun BirthdayResult.happyBirthday2(): BirthdayResult = (BirthdayResult.people compose Every.list() compose Person.age) .modify(this) { it + 1 }

We would like to stress that the biggest benefit of using optics is the uniformity of their API. Only two operations, compose and modify, were needed to define nested transformations of immutable data. Although getting used to this style of programming takes a bit of time, being acquainted with optics is definitely useful in the longer term.

2:

Projects like Lombok, which automatizes the generation of "dummy" getters, setters, and equality functions, show that this pattern is really widespread.

3:

Please check the instructions on how to enable it for your particular project set-up (at the time of writing, there are important differences depending on whether you need Multiplatform support or not).

4:

For technical reasons, a companion object (even if empty) is required for the plug-in to work.