Skip to main content

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()
)
)
)
note

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.

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
}
Annoying 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:

  1. 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() }
  2. Copy builder: Arrow Optics provides an overload of copy that, instead of named arguments, takes a block. Inside that block, you can use the syntax optic 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 TraversalOptionalLens, 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.

Even more optics

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.