Exit e-book
Show all chapters
06
Composition of Optics
06. 
Composition of Optics
Improve your focus with ZIO optics
06

Composition of Optics

From the previous sections, you could think that what optics essentially do, is to turn the getters and setters we know and love from OOP into values, which are type-safe, principled, and composable. Composability is where the real power of optics comes in. We’ll now see some examples of how to use that power.

Example 1: Sequential composition of Lenses

Let’s revisit the first example we looked at, in the Overview of Optics section:

final case class Person(fullName: String, address: Address)
final case class Address(city: String, street: Street)
final case class Street(name: String, number: Int)

In that example we wrote a setStreetNumber method:

def setStreetNumber(person: Person, newStreetNumber: Int): Person =
  person.copy(
    address = person.address.copy(
      street = person.address.street.copy(
        number = newStreetNumber
      )
    )
  )

Let’s rewrite that method using optics. Firstly, we can define some Lenses for each case class:

final case class Person(fullName: String, address: Address)
object Person {
 val fullName: Lens[Person, String] =
   Lens(
     person => Right(person.fullName),
     newFullName => person => Right(person.copy(fullName = newFullName))
   )

 val address: Lens[Person, Address] =
   Lens(
     person => Right(person.address),
     newAddress => person => Right(person.copy(address = newAddress))
   )
}

final case class Address(city: String, street: Street)
object Address {
 val city: Lens[Address, String] =
   Lens(
     address => Right(address.city),
     newCity => address => Right(address.copy(city = newCity))
   )

 val street: Lens[Address, Street] =
   Lens(
     address => Right(address.street),
     newStreet => address => Right(address.copy(street = newStreet))
   )
}





final case class Street(name: String, number: Int)
object Street {
 val name: Lens[Street, String] =
   Lens(
     street => Right(street.name),
     newName => street => Right(street.copy(name = newName))
   )

 val number: Lens[Street, Int] =
   Lens(
     street => Right(street.number),
     newNumber => street => Right(street.copy(number = newNumber))
   )
}

The new version of setStreetNumber would be:

def setStreetNumber(person: Person, newStreetNumber: Int): Either[Nothing, Person] = {
  val streetNumber: Lens[Person, Int] =
    Person.address >>> Address.street >>> Street.number
  streetNumber.set(newStreetNumber)(person)
}

As a first step, you can see a streetNumber Lens has been created. The idea of this Lens is that it should allow us to access the street number inside of a Person.  We can do that using the >>> operator, which allows us to do sequential composition of Optics, so that we can combine the Lenses we have already created for each individual case class.

 

The example above is read like this:

  • Create a Lens to access the address inside Person
  • And then access the street inside Address
  • And then access the number inside Street

The next step is just to use the streetNumber Lens to modify the given person with the given newStreetNumber. As you can see, this new version is more declarative and easier to understand than the original.

Example 2: Zipping two Lenses

Continuing with the previous example, what if we wanted a Lens that returned two values instead of one, for example the fullName and the street number where a Person lives? We can use the zip operator for that:

val fullNameAndStreetNumber: Lens[Person, (String, Int)] =
  Person.fullName zip streetNumber

 

Example 3: Sequential composition of Lenses and Prisms

It’s important to mention that we can compose any type of optics with any other type of optics, not just Lens with Lens. This is because, as we have seen before, all of the optics in ZIO Optics are just aliases of the one and only Optic data type!

For instance, in the Overview of Optics section we had this other example, which is basically the same as the previous, but with fields which are of type Option:

final case class OPerson(fullName: Option[String], address: Option[OAddress])
final case class OAddress(city: Option[String], street: Option[OStreet])
final case class OStreet(name: Option[String], number: Option[Int])

The original implementation of setStreetNumberOptional was like this:

def setStreetNumberOptional(person: OPerson, newStreetNumber: Int): Either[String, OPerson] =
 person.address match {
   case Some(address) =>
     address.street match {
       case Some(street) =>
         Right(
           OPerson(
             fullName = person.fullName,
             address = Some(OAddress(address.city, Some(OStreet(street.name, Some(newStreetNumber)))))
           )
         )
       case None => Left("Empty street")
     }
   case None => Left("Empty address")
 }

And the implementation with optics would be:

​​def setStreetNumberOptional(person: OPerson, newStreetNumber: Int): Either[OpticFailure, OPerson] = {
  val streetNumber =
    OPerson.address >>> Prism.some >>>
      OAddress.street >>> Prism.some >>>
      OStreet.number >>> Prism.some
  streetNumber.set(newStreetNumber)(person)
}

Looks a lot better, right? The only thing we needed to do is to write a different streetNumber Optic. And as you can see, in this case, we are composing not just Lenses with Lenses, but we also have some Prisms in the mix now.

The composed Optic would read like this:

  • Create an Optic to access the address inside of OPerson
  • And then access the Some case of the corresponding Option[Address]
  • And then access the street inside of Address
  • And then access the Some case of the corresponding Option[Street]
  • And then access the number inside Street

And then access the Some case of the corresponding Option[Int]

Example 4: Optional as composition of Prism and Lens

For another example of composing Lenses with Prisms, recall that in a previous section we defined an Optional for accessing the phone number of this ContactInfo ADT:

sealed trait ContactInfo
object ContactInfo {
  final case class Phone(number: Int)     extends ContactInfo
  final case class Email(address: String) extends ContactInfo
}

We defined the Optional by using the Optional.apply constructor. But it turns out we can also define it as a sequential composition of Prism[ContactInfo, Phone] and Lens[Phone, Int] as well. So, let’s define some Lenses and Prisms first:

sealed trait ContactInfo
object ContactInfo {
  final case class Phone(number: Int) extends ContactInfo
  object Phone {
    val number: Lens[Phone, Int] =
      Lens(
        phone => Right(phone.number),
        newNumber => phone => Right(phone.copy(number = newNumber))
      )
  }

  final case class Email(address: String) extends ContactInfo
  object Email {
    val address: Lens[Email, String] =
      Lens(
        email => Right(email.address),
        newAddress => phone => Right(phone.copy(address = newAddress))
      )
  }

  val phone: Prism[ContactInfo, Phone] =
    Prism(
      {
        case p: Phone => Right(p)
        case _        => Left(OpticFailure("Not a Phone"))
      },
      Right(_)
    )



  val email: Prism[ContactInfo, Email] =
    Prism(
      {
        case e: Email => Right(e)
        case _        => Left(OpticFailure("Not an Email"))
      },
      Right(_)
    )
}

So now, we can define an Optional for accessing the phone number:

val phoneNumber: Optional[ContactInfo, Int] =
  ContactInfo.phone >>> ContactInfo.Phone.number

We could also define an Optional for accessing the email address in a similar way:

val emailAddress: Optional[ContactInfo, String] =
  ContactInfo.email >>> ContactInfo.Email.address

 

Example 5: Composing two Optics with orElse

Continuing with the previous example, what if we want to define an Optic that:

  • Tries to access the phone number of a ContactInfo
  • If it fails, it should try to get the email address

It turns out we can do this with the orElse operator, by combining the Optionals we have created in the previous example:

val phoneNumberOrEmailAddress = phoneNumber orElse emailAddress

Example 6: Using Optics to access deeply nested collections

For this, let’s revisit the last example in the Overview of Optics section. We had a Map of Customers, indexed by customer ID:

val customers: Map[Long, Customer] = Map(
  1000L -> Customer("Mary Lopez", Map(1L    -> Order(1, 100), 2L -> Order(2, 200))),
  2000L -> Customer("David Adams", Map(1L   -> Order(3, 300), 2L -> Order(4, 400))),
  3000L -> Customer("Brian Johnson", Map(1L -> Order(5, 500), 2L -> Order(6, 600)))
)

The basic data structures were:

final case class Order(itemId: Long, quantity: Long)
final case class Customer(name: String, orders: Map[Long, Order])

We implemented a setQuantity method to modify the quantity of an Order, given the customer ID and order ID. Our implementation had lots of boilerplate, so now let’s use ZIO Optics instead. First, let’s create some Lenses for Order and Customer:

final case class Order(itemId: Long, quantity: Long)
object Order {
  val quantity: Lens[Order, Long] =
    Lens(
      order => Right(order.quantity),
      newQuantity => order => Right(order.copy(quantity = newQuantity))
    )
}

final case class Customer(name: String, orders: Map[Long, Order])
object Customer {
  val orders: Lens[Customer, Map[Long, Order]] =
    Lens(
      customer => Right(customer.orders),
      newOrders => customer => Right(customer.copy(orders = newOrders))
    )
}

We can now rewrite the setQuantity method:

def setQuantity(
 customers: Map[Long, Customer],
 customerId: Long,
 orderId: Long,
 newQuantity: Long
): Either[OpticFailure, Map[Long, Customer]] = {
  val quantityOptic =
    Optic.key[Long, Customer](customerId) >>> Customer.orders >>> Optic.key(orderId) >>> Order.quantity
  quantityOptic.set(newQuantity)(customers)
}

This new solution is a lot more elegant than the original, and again we are just using optics composition. The composed quantityOptic is read like this:

  • Create an Optic to access the customerId key inside a Map[Long, Customer]
  • And then access the orders inside the corresponding Customer
  • And then access the orderId key inside the corresponding Map[Long, Order]

And then access the quantity inside Order

Example 7: Using the foreach operator of Traversal

It turns out that, when we have a Traversal, we can call the Traversal#foreach operator on it, which expects another Optic to be applied to each item accessed by the aforementioned Traversal.

For example, let’s say we have a Chunk[Person], and we want to get the names of every Person who lives in New York. We already have the Person type from previous examples, and we’ve also already defined Lenses for it, so here’s how we would solve this problem by composing Optics:

def getPeopleNamesFromNewYork(
  people: Chunk[Person]
): Either[OpticFailure, Chunk[String]] = {
  val newYorkNames =
    Traversal.filter[Person](_.address.city == "New York").foreach(Person.fullName)
  newYorkNames.get(people)
}

You can see we have defined a newYorkNames Optic, which uses the Traversal#foreach operator to compose a Traversal.filter optic with a Lens we already have: Person.fullName. After the Optic is defined, we can just use it to get the desired items from the given people.

So, if we have:

val people = Chunk(
  Person("John Adams", Address("New York", Street("Some street", 100))),
  Person("Juanita Perez", Address("Los Angeles", Street("Another street", 500))),
  Person("Lucy Smith", Address("New York", Street("Some street", 200))),
  Person("Andrew Johns", Address("San Diego", Street("The street", 600)))
)

Then, the result of calling getPeopleNamesFromNewYork would be:

getPeopleNamesFromNewYork(people)
// Right(Chunk(John Adams,Lucy Smith))

 

PREVIOUS
Chapter
05
NEXT
Chapter
07