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

Overview of ZIO Optics

Why Optics matter

Optics are a very important tool in FP. They allow developers to easily access and manipulate deeply nested immutable data structures.

For example, let’s say we have a Person case class, which contains an Address case class inside, which in turn contains a Street case class inside:

 

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

If we want to change the number of the street where a Person lives, just using plain Scala with no optics, we will need to do something like this:

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

That’s a lot of ceremony, and if we had defined Person as a normal class instead of a case class, it would be even worse, because we wouldn’t have the copy method at our disposal.

But, if we were doing OOP, Person would be a mutable data structure:

final class MutablePerson(var fullName: String, var address: MutableAddress)
final class MutableAddress(var city: String, var street: MutableStreet)
final class MutableStreet(var name: String, var number: Int)

That means, setting a new street number would now be a piece of cake:

def setStreetNumberMutable(person: MutablePerson, newStreetNumber: Int): Unit =
  person.address.street.number = newStreetNumber

Here is where OOP really shines: mutating deeply nested records.  That’s why we’d like to have something similar in FP, which is precisely where optics steps in. We’ll be seeing how in the following sections, so stay tuned!

Having said that, there are other cases that are really hard to handle in OOP. For example, in OOP it’s typical to work with null values, right? In that case, the setStreetNumberMutable method gets a lot more complicated:

def setStreetNumberMutableNull(person: MutablePerson, newStreetNumber: Int): Unit =
  if (person != null) {
    if (person.address != null) {
      if (person.address.street != null) {
        person.address.street.number = newStreetNumber
      } else {
        throw new Exception("Null street!")
      }
    } else {
      throw new Exception("Null address!")
    }
  } else {
    throw new Exception("Null person!")
  }

On the other hand, in FP we don’t work with null values, we use the Option data type instead. So, our data models would have to be like this:

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])

So setStreetNumber would look 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")
  }

Lots of boilerplate, right? And as demonstrated before, OOP didn’t have a good solution either for this scenario. However, in FP, optics could help a lot.

Finally, let’s consider one more example. Let’s say we have 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 are:

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

Let’s implement a method to modify the quantity of an Order, given the customer ID and order ID, using FP:

def setQuantity(
 customers: Map[Long, Customer],
 customerId: Long,
 orderId: Long,
 newQuantity: Long
): Either[String, Map[Long, Customer]] =
  customers.get(customerId) match {
    case Some(customer) =>
      customer.orders.get(orderId) match {
        case Some(order) =>
          Right(
            customers.updated(
              customerId,
              customer.copy(
                orders = customer.orders.updated(
                  orderId,
                  order.copy(quantity = newQuantity)
                )
              )
            )
          )
        case None =>
          Left(s"Order with ID $orderId does not exist")
      }
    case None =>
      Left(s"Customer with ID $customerId does not exist")
  }

Again, lots of boilerplate. And if we tried to implement that solution with OOP, you can imagine it wouldn’t be great either. Once more, optics would be a perfect solution to solve this problem in an elegant way.

Enter ZIO Optics

ZIO Optics is a new library in the ZIO ecosystem, which as its name implies provides an implementation of optics.

There are other optics libraries for Scala such as Monocle, which is really great, but ZIO Optics has some very important features. For example, other libraries provide just Pure Optics, meaning optics that only work with pure data living in memory. On the other hand, ZIO Optics provides not just that, but also Effectful Optics.   These are very powerful because they can interact with the outside world.   They do some IO to work with data that doesn’t necessarily live in memory, but comes from external sources such as databases, files, APIs, etc. Not only that, ZIO Optics also provides Transactional Optics, which are able to participate in STM transactions. If you want to know more about ZIO STM, you can take a look at this article. Another very important differentiator of ZIO Optics is that it’s designed to work in a concurrent, multi-threaded environment. Optics in other libraries are designed for a world of just one thread of execution.

Some other important characteristics of ZIO Optics are: in 

  • It has a completely different encoding for optics, which enables superior composability. We’ll be seeing more on this later, in the section on composition of optics.
  • It offers clear error messages for easy diagnostics.
  • Since it uses Scala modules from the very beginning, it’s very extensible. This means, for example, you could easily extend ZIO Optics to integrate with other effect systems, such as Cats-Effect or Monix.
  • It has full integration with the ZIO ecosystem.

How to use ZIO Optics in your project

If you want to use ZIO Optics in your project, you just need to add the following dependency to your build.sbt file:

libraryDependencies += "dev.zio" %% "zio-optics" % "0.1.0"

 

The Optic data type

The core data type in the ZIO Optics library is called Optic, and it is just a combination of two functions, a getter and a setter:

case class Optic[
  -GetWhole,
  -SetWholeBefore,
  -SetPiece,
  +GetError,
  +SetError,
  +GetPiece,
  +SetWholeAfter
](
  getOptic: GetWhole => OpticResult[(GetError, SetWholeAfter), GetPiece],
  setOptic: SetPiece => SetWholeBefore => OpticResult[(SetError, SetWholeAfter), SetWholeAfter]
)

You can see Optic has several type parameters, seven of them in all! The reason it has so many parameters is to enable maximum flexibility so that all kinds of optics can be simply derived from this single data type.

From the ZIO Optics documentation we get an explanation of what the type parameters mean:

  • The getter is just a function that can take some larger data structure (i.e. the whole) of type GetWhole and get a part of it (i.e. a piece) of type GetPiece. It can potentially fail with an error of type GetError because the piece we are trying to get might not exist in the whole.
  • The setter is another function that has the ability, given some piece of type SetPiece and an original whole of type SetWholeBefore, to return a new structure of type SetWholeAfter. Setting can fail with an error of type SetError because the piece we are trying to set might not exist in the whole.

If you are a little confused about this, don’t worry because we normally won’t need to interact directly with this Optic data type. Instead, we are going to work with specific cases of Optic which are a lot easier to reason about. The idea of the Optic data type in ZIO Optics is that, if we play around with its type parameters, we can get different types of optics for free. We’ll be exploring them in the next sections.

General categories of optics in ZIO Optics

In the previous section, you could see that both the getter and the setter of an Optic return a value of type OpticResult[+E, +A]. This represents the result of executing an Optic which can be either a success of type A or a failure of type E.

OpticResult[E, A] is just an abstract type that changes according to the Optic category, and ZIO Optics has three of them in general:

  • Pure Optics: Where OpticResult[E, A] = Either[E, A], meaning these optics can just return pure values.
  • Effectful Optics: Where OpticResult[E, A] = IO[E, A], meaning these optics can return ZIO effects, which opens a whole new space of possibilities.  That’s because it implies we can have optics that interact with the outside world and are not limited to working with data in memory.
  • Transactional Optics: Where OpticResult[E, A] = STM[E, A], meaning these optics can participate in ZIO STM transactions.
PREVIOUS
Chapter
01
NEXT
Chapter
03