Skip to main content

Lenses

Lenses are the most common type of optic you work with. This section discusses them at length.

In a rush?
  • Lenses represent references to fields.
  • To access the value, use get.
  • To modify the value, use set and modify.
  • To modify several elements at once, use copy.

The Lens type

We've mentioned in the introduction that optics are values that represent access to data. You can draw parallels with how function values represent behavior.

Let's introduce a few data classes and kindly ask the Arrow Optics plug-in to generate lenses for every field by having an @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
}

The lenses are generated in the companion object, so you can think of your Person being extended as follows:

data class Person(val name: String, val age: Int, val address: Address) {
companion object {
val name: Lens<Person, String> = TODO()
val age: Lens<Person, Int> = TODO()
val address: Lens<Person, Address> = TODO()
}
}

Notice that lenses in Arrow are typed, which means that they "know" both the type of the larger value and the type of the element we focus on.

                  ↱ this lens operates on 'Person'
val address: Lens<Person, Address>
↳ this lens gives access to an 'Address' value

Operations

Lenses provide three primary operations:

  • get obtains the elements focused on a lens.
  • set changes the value of the focus to a new one.
  • modify transforms the value of the focus by applying a given function.

Code speaks louder than words (well, sometimes). Here's a small snippet showcasing the three operations applied to an instance of our Person class. Notice how the three operations live on the lens and get the value they operate on as an argument.

fun example() {
val me = Person(
"Alejandro", 35,
Address(Street("Kotlinstraat", 1), City("Hilversum", "Netherlands"))
)

Person.name.get(me) shouldBe "Alejandro"

val meAfterBirthdayParty = Person.age.modify(me) { it + 1 }
Person.age.get(meAfterBirthdayParty) shouldBe 36

val newAddress = Address(Street("Kotlinplein", null), City("Amsterdam", "Netherlands"))
val meAfterMoving = Person.address.set(me, newAddress)
Person.address.get(meAfterMoving) shouldBe newAddress
}

Composition

The power of lenses (and optics in general) lies in the ability to compose them to get to nested values. The type parameters in Lens ensure that the composition accesses values that are really there. For example, here's a lens that focuses on the city where a Person lives:

val personCity: Lens<Person, String> =
Person.address compose Address.city compose City.name

fun example() {
val me = Person(
"Alejandro", 35,
Address(Street("Kotlinstraat", 1), City("Hilversum", "Netherlands"))
)

personCity.get(me) shouldBe "Hilversum"
val meAtTheCapital = personCity.set(me, "Amsterdam")
}

The compose infix function is an integral part of the library, but you almost never see it mentioned explicitly. As part of its job, the Arrow Optics compiler plug-in introduces additional extension functions that allow you to use the regular dot operation to access composed lenses. The code above can be rewritten in that form:

fun example() {
val me = Person(
"Alejandro", 35,
Address(Street("Kotlinstraat", 1), City("Hilversum", "Netherlands"))
)

Person.address.city.name.get(me) shouldBe "Hilversum"
val meAtTheCapital = Person.address.city.name.set(me, "Amsterdam")
}

More powerful copy

Everything we've discussed to this point is enough to make the transformation of nested data much nicer without the nesting of nested copy calls. However, if we need to modify more than one field, we must nest calls to set or modify.

fun Person.moveToAmsterdamModify(): Person =
Person.address.city.name.set(
Person.address.city.country.set(this, "Netherlands"),
"Amsterdam"
)

Arrow Optics provides a copy function that replicates the built-in copy ability to modify more than one field. The syntax is slightly different, though. After the copy, you need to start a block. And within that block, you can use the name of a lens to perform an operation.

fun Person.moveToAmsterdamCopy(): Person = copy {
Person.address.city.name set "Amsterdam"
Person.address.city.country set "Netherlands"
}

Another nicety is that you can condense those operations that share part of the journey to their focus. In our case, we are modifying two elements in address.city, which we can join using inside.

fun Person.moveToAmsterdamInside(): Person = copy {
inside(Person.address.city) {
City.name set "Amsterdam"
City.country set "Netherlands"
}
}

Sealed class hierarchies

If you have a set of classes with a common sealed parent, then lenses can be generated for those properties shared by all of them. Those properties must appear already in the common parent.

For example, the plug-in generates a lens of the name field given the code below. This lens complements the prisms that are generated to focus on each of the two subclasses.

@optics sealed interface SUser {
val name: String

companion object
}

@optics data class SPerson(
override val name: String,
val age: Int
): SUser {
companion object
}

@optics data class SCompany(
override val name: String,
val vat: VATNumber
): SUser {
companion object
}

Integration with Compose

If you are using Compose, either in Android or Multiplaftorm flavors, you often need to update a MutableState by applying some modification to the previous value. The arrow-optics-compose package provides a version of copy useful in those situations.

class AppViewModel: ViewModel() {
private val _personData = mutableStateOf<Person>(...)

fun updatePersonalData(
newName: String, newAge: Int
) {
_personData.updateCopy {
Person.name set newName
Person.age set newAge
}
}
}
Compose and Snapshots

updateCopy uses the snapshot system in Compose to ensure that all the modifications in the block happen atomically.