Introduction
Data classes, sealed hierarchies, and above all, immutable data is an excellent recipe for domain modeling. If we want to model a domain sharply, we often end up with a large amount of (nested) classes, each of them representing a particular kind of object.
data class Person(val name: String, val age: Int, val address: Address)
data class Address(val street: Street, val city: City)
data class Street(val name: String, val number: Int?)
data class City(val name: String, val country: String)
Alas, Kotlin doesn't provide great tooling out of the box to transform those
values. Data classes have a built-in copy
method, but we need to repeat the
name of the fields and perform iterated copies even if we only want to touch
one single field.
fun Person.capitalizeCountry(): Person =
this.copy(
address = address.copy(
city = address.city.copy(
country = address.city.country.capitalize()
)
)
)
We often use the word transform even though we are talking about immutable data. In most cases, we refer to creating a copy of the value where some of the fields are modified.
Meet optics
Arrow provides a solution to this problem in the form of optics. Optics
are values that represent access to a value (or values) inside a larger
value. For example, we may have an optic focusing (that's the term we use)
on the address
field of a Person
. By combining different optics, we can
concentrate on nested elements, like the city
field within the address
field
within a Person
. But code speaks louder than words, so let's see how the
example above improves using optics.
Immutable data manipulation with optics is available through the arrow-optics
library, and the corresponding arrow-optics-ksp-plugin
compiler plug-in.
The easiest way to start with Arrow Optics is through its compiler
plug-in. After adding it to your build
you just need to mark each class for which you want optics generated
with the @optics
annotation.
import arrow.optics.*
@optics data class Person(val name: String, val age: Int, val address: Address) {
companion object
}
@optics data class Address(val street: Street, val city: City) {
companion object
}
@optics data class Street(val name: String, val number: Int?) {
companion object
}
@optics data class City(val name: String, val country: String) {
companion object
}
You need to have a companion object
declaration in each class, even if it's empty.
This is due to limitations in KSP,
the compiler plug-in framework used to implement the Arrow Optics plug-in.
The plug-in generates optics for each field, available under the class name.
For example, Person.address
is the optic focusing on the address
field.
Furthermore, you can create optics that focus on nested fields using the
same dot notation you're used to. In this case,
Person.address.city.country
represents the optic focusing precisely on
the field we want to transform. By using it, we can reimplement capitalizeCountry
in two ways:
-
Optic-first: the
modify
operation of an optic takes an entire value (this
in the example) and the transformation to apply to the focused element.fun Person.capitalizeCountryModify(): Person =
Person.address.city.country.modify(this) { it.capitalize() } -
Copy builder: Arrow Optics provides an overload of
copy
that, instead of named arguments, takes a block. Inside that block, you can use the syntaxoptic transform operation
to modify a focused element.fun Person.capitalizeCountryCopy(): Person =
this.copy {
Person.address.city.country transform { it.capitalize() }
}
Many optics to rule them all
You may have noticed that we speak about optics. In fact, there are a few important kinds that differ in the amount of elements they can potentially focus on. All the optics in the example above are lenses, which have precisely one focus. At the other end of the spectrum, we have traversals, which focus on any amount of elements; they can be used to uniformly modify all the elements in a list, among other operations. Optics form a hierarchy that we can summarize in the diagram below.
The "main line" of optics is Traversal
→ Optional
→ Lens
, which differ
only in the number of elements they focus on. Prism
and Iso
add a slight
twist: they allow not only modifying, but also creating new values and
matching over them.
Arrow 1.x features a larger hierarchy of optics because the operations of "getting" values and "modifying" them live in different interfaces. Arrow 2.x simplifies the hierarchy to the five optics described in this section.