A birds-eye view of Arrow: Data Immutability with Arrow Optics
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:
var, even to the point of forbidding
- 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:
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.
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.
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.
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.
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.
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.
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:
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.
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
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.
We would like to stress that the biggest benefit of using optics is the uniformity of their API. Only two operations,
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.
Projects like Lombok, which automatizes the generation of "dummy" getters, setters, and equality functions, show that this pattern is really widespread.
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).
For technical reasons, a companion object (even if empty) is required for the plug-in to work.