Exit e-book
Show all chapters
03
Exploring Pure Optics
03. 
Exploring Pure Optics
Improve your focus with ZIO optics
03

Exploring Pure Optics

As mentioned above, Pure Optics are the ones where OpticResult[E, A] = Either[E, A]. So, we have:

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

To work with Pure Optics, just include the following import:

import zio.optics._

Let’s explore now the different kinds of Pure Optics we have at our disposal.

Lens

A Lens is an Optic that accesses a field of a product type, which is a specific category of so called Algebraic Data Types. If you don’t know what ADTs are, I recommend reading this article from the Functional Programming Simplified book, by Alvin Alexander. Examples of product types are tuples or case classes. In ZIO Optics, a Lens is represented by the following type alias:

type Lens[S, A] = Optic[S, S, A, Nothing, Nothing, A, S]

So, the simplified signature of Optic for Lens would be like this:

case class Lens[S, A](
  getOptic: S => Either[Nothing, A],
  setOptic: A => S => Either[Nothing, S]
)

Please notice the following:

  • The GetError and SetError types of a Lens are Nothing because getting or setting a field of a product type cannot fail (the field will always be present).
  • The GetWhole, SetWholeBefore, and SetWholeAfter types are the same and represent the product type S we are working with.
  • The GetPiece and SetPiece types are also the same and represent the field A we are trying to access.

To have a cleaner mental model of a Lens, we can simplify its signature even further:

case class Lens[S, A](
  getOptic: S => A,
  setOptic: A => S => S
)

This way, it’s easier to realize that a Lens is an Optic with which we can always:

  • Get a field A of a product type S.

Set a new value for a field of a product type S, given a new value A and the original structure S.

Examples with Lens

Let’s now see how to construct some Lenses. Imagine we have the following Person product type:

final case class Person(name: String, age: Int)

Because this Person type has two fields, we can create two Lenses. Let’s first create a Lens for the name field, and let’s put it inside the Person companion object:

import zio.optics._

object Person {
  val name: Lens[Person, String] =
    Lens(
      person => Right(person.name),
      newName => person => Right(person.copy(name = newName))
    )
}

Note that, to create a Lens, we just need to call the Lens.apply method providing a getter and a setter for the field we want to access (in this case: name).

And, creating a Lens for the age field is very similar:

import zio.optics._

object Person {
  ...

  val age: Lens[Person, Int] =
    Lens(
      person => Right(person.age),
      newAge => person => Right(person.copy(age = newAge))
    )
}

We can now use these new Lenses we’ve just defined, to get and set values of any Person object:

val person1 = Person("Juanito", 25)

// Get `name` from person1
val name1: Either[Nothing, String] = Person.name.get(person1)
// Right(Juanito)

// Get `age` from person1
val age1: Either[Nothing, Int] = Person.age.get(person1)
// Right(25)




// Change `name` in person1 to Pepito
val person2: Either[Nothing, Person] = Person.name.set("Pepito")(person1)
// Right(Person(Pepito, 25))

// Change `age` in person1 to 27
val person3: Either[Nothing, Person] = Person.age.set(27)(person1)
// Right(Person(Juanito, 27))

// Increase `age` of person1 by 5
val person4: Either[Nothing, Person] = Person.age.update(person1)(_ + 5)
// Right(Person(Juanito, 30))

Finally, ZIO Optics includes some built-in Lenses. For example:

val first  = Lens.first  // Get the first element of a Tuple2
val second = Lens.second // Get the second element of a Tuple2

 

Prism

A Prism is an Optic that accesses a case of a sum type (also called coproduct type), such as the Left or Right cases of an Either or one of the subtypes of a sealed trait. In ZIO Optics, a Prism is represented by the following type alias:

type Prism[S, A] = Optic[S, Any, A, OpticFailure, Nothing, A, S]

So, the simplified signature of Optic for Prism would be like this:

case class Prism[S, A](
  getOptic: S => Either[OpticFailure, A],
  setOptic: A => Any => Either[Nothing, S]
)

This means that:

  • The GetError type of a Prism is OpticFailure because getting a case of a sum type can fail when the case we are trying to access does not exist. For example, we might be trying to access the right side of an Either but the instance we are working with is actually a Left. By the way, in ZIO Optics the OpticFailure type models the different ways getting or setting can fail.
  • The SetError type is Nothing because given one of the cases of a sum type we can always return a new value of the aforementioned sum type.
  • The GetWhole and SetWholeAfter types are the same and represent the sum type S.
  • The SetWholeBefore type is set to Any because we don’t need any original structure to set a new value. This is different from Lens, where we needed the original structure. A sum type consists of nothing but its cases so, if we have a new value of the case we want to set, we can just use that value and we don’t need the original structure.
  • The GetPiece and SetPiece types are the same and represent the case A we are trying to access.

To have a cleaner mental model of a Prism, we can simplify its signature even further:

case class Prism[S, A](
  getOptic: S => Either[OpticFailure, A],
  setOptic: A => S
)

This way, it’s easier to realize that a Prism is an Optic with which we can:

 

  • Get a case A of a sum type S. This can fail with an OpticFailure.
  • Set a new value for a sum type S, given a new case A.

Examples with Prism

Suppose we have the following sum type for Json values:

sealed trait Json
object Json {
  final case object JNull                     extends Json
  final case class JStr(v: String)            extends Json
  final case class JNum(v: Double)            extends Json
  final case class JArr(v: Vector[Json])      extends Json
  final case class JObj(v: Map[String, Json]) extends Json

  type JNull = JNull.type
}

Because this Json type has five cases, we can create five Prisms. Let’s first create a Prism for the JNull case, and let’s put it inside the Json companion object:

import zio.optics._

object Json {
  ...

  val jNull: Prism[Json, JNull] =
    Prism(
       {
         case json: JNull => Right(json)
         case _           => Left(OpticFailure("Not a JNull"))
       },
       Right(_)
    )
}

So, to create a Prism, we just need to call the Prism.apply method providing a getter and a setter for the case we want to access (JNull). You can see that:

  • The getter can fail with an OpticFailure when trying to get values that are not of type JNull.
  • The setter succeeds right away with the provided JNull.

And, creating Prisms for other cases is very similar:

import zio.optics._

object Json {
  ...

  val jStr: Prism[Json, JStr] =
     Prism(
      {
        case json: JStr => Right(json)
        case _          => Left(OpticFailure("Not a JStr"))
      },
      Right(_)
    )

  val jNum: Prism[Json, JNum] =
    Prism(
      {
        case json: JNum => Right(json)
        case _          => Left(OpticFailure("Not a JNum"))
      },
      Right(_)
    )

  val jArr: Prism[Json, JArr] =
    Prism(
      {
        case json: JArr => Right(json)
        case _          => Left(OpticFailure("Not a JArr"))
      },
      Right(_)
    )

   
  val jObj: Prism[Json, JObj] =
    Prism(
      {
        case json: JObj => Right(json)
        case _          => Left(OpticFailure("Not a JObj"))
      },
      Right(_)
    )
}

We can now use these new Prisms we’ve just defined to get and set values of any Json object:

val json1 = Json.JNum(100)
val json2 = Json.JStr("hello")

// Get a JNum from json1
val jNum1: Either[OpticFailure, Json.JNum] = Json.jNum.get(json1)
// Right(JNum(100.0))

// Get a JNum from json2
val jNum2: Either[OpticFailure, Json.JNum] = Json.jNum.get(json2)
// Left(OpticFailure(Not a JNum))

// Set Json to JStr, notice we don't need the whole!
val json3: Either[Nothing, Json] = Json.jStr.set(Json.JStr("some text"))
// Right(JStr(some text))




// Update json1 duplicating its value
val json4: Either[OpticFailure, Json] = Json.jNum.update(json1) {
 case Json.JNum(x) => Json.JNum(x * 2)
}
// Right(JNum(200.0))

// Update json2 duplicating its value
val json5: Either[OpticFailure, Json] = Json.jNum.update(json2) {
 case Json.JNum(x) => Json.JNum(x * 2)
}
// Left(OpticFailure(Not a JNum))

And by the way, ZIO Optics includes some built-in Prisms, for example:

val cons  = Prism.cons  // Access the :: case of a List
val left  = Prism.left  // Access the Left case of an Either
val right = Prism.right // Access the Right case of an Either
val none  = Prism.none  // Access the None case of an Option
val some  = Prism.some  // Access the Some case of an Option

Iso

An Iso is an Optic that accesses a part of a data structure which consists of nothing else but the part. In ZIO Optics, an Iso is represented by the following type alias:

type Iso[S, A] = Optic[S, Any, A, Nothing, Nothing, A, S]

The simplified signature of Optic for Iso would therefore be like this:

case class Iso[S, A](
  getOptic: S => Either[Nothing, A],
  setOptic: A => Any => Either[Nothing, S]
)

Please notice the following:

  • The GetError and SetError types of an Iso are Nothing, meaning that the conversion from S to A and from A to S will always succeed.
  • The GetWhole, SetWholeBefore, and SetWholeAfter types are the same type S.
  • The GetPiece and SetPiece types are the same type A.

To have a cleaner mental model of an Iso, we can simplify its signature even further:

case class Iso[S, A](
  getOptic: S => A,
  setOptic: A => S
)

This way, you can think of an Iso as an optic which converts elements of type S into elements of type A and vice versa, losslessly. And, in fact, it’s called Iso because it stands for an Isomorphism, which is just a fancy word for Equivalence. As a curiosity, ZIO Prelude includes its own Equivalence data type.

 

It’s also worth noticing that an Iso is like a combination of a Lens and a Prism, because:

 

  • The getter in Iso is the same as the getter in Lens.
  • The setter in Iso is the same as the setter in Prism.

Examples with Iso

Here we have a Color type that just consists of one part, an rgb value:

final case class Color(rgb: Int)

We can create an Iso inside the Color companion object:

import zio.optics._

object Color {
  val iso: Iso[Color, Int] =
    Iso(
      color => Right(color.rgb),
      rgb => Right(Color(rgb))
    )
}

So, to create an Iso, we just need to call the Iso.apply method providing a getter (function to convert from Color to Int) and a setter (function to convert from Int to Color).

We can now use this new Iso we’ve just defined:

val color1 = Color(255)

// Get the rgb value of color1
val rgb1: Either[Nothing, Int] = Color.iso.get(color1)
// Right(255)

// Create a Color with rgb=4
val color2: Either[Nothing, Color] = Color.iso.set(4)
// Right(Color(4))

Finally, ZIO Optics includes a built-in Iso:

val identity = Iso.identity // The identity Optic

This Iso is interesting, because it represents just a pair of identity functions.

Optional

An Optional is a more general Optic than Lens or Prism, because it’s used to access a piece of any ADT, where that part may or may not exist. In ZIO Optics, an Optional is represented by the following type alias:

type Optional[S, A] = Optic[S, S, A, OpticFailure, OpticFailure, A, S]

SollThe simplified signature of Optic for Optional would therefore now be like this:

case class Optional[S, A](
  getOptic: S => Either[OpticFailure, A],
  setOptic: A => S => Either[OpticFailure, S]
)

Please notice the following:

  • The GetError and SetError types of an Optional are OpticFailure because getting or setting a piece of an ADT can fail when it does not exist.
  • The GetWhole, SetWholeBefore, and SetWholeAfter types are the same and represent the ADT S.
  • The GetPiece and SetPiece types are the same and represent the piece A we are trying to access.

Examples with Optional

Let’s say we have the following ContactInfo ADT:

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

We can create an Optional for accessing the phone number, inside the ContactInfo companion object:

import zio.optics._

object ContactInfo {
  ...

  val phoneNumber: Optional[ContactInfo, Int] =
    Optional(
      {
        case Phone(number) => Right(number)
        case _             => Left(OpticFailure("Contact info does not contain a phone number!"))
      },
      newPhoneNumber =>
        contactInfo =>
          contactInfo match {
            case Phone(_) => Right(Phone(newPhoneNumber))
            case _        => Left(OpticFailure("Can't set phone number"))
          }
    )
}

So, to create an Optional, we just need to call the Optional.apply method providing a getter and a setter for the part we want to access (number inside Phone for this case). You can see the getter/setter can fail with an OpticFailure when trying to get/set a number from values that are not of type Phone.

Creating an Optional for accessing an email address is very similar:

import zio.optics._

object ContactInfo {
  ...

  val emailAddress: Optional[ContactInfo, String] =
    Optional(
      {
        case Email(address) => Right(address)
        case _              => Left(OpticFailure("Contact info does not contain an email address!"))
      },
      newEmailAddress =>
        contactInfo =>
          contactInfo match {
            case Email(_) => Right(Email(newEmailAddress))
            case _        => Left(OpticFailure("Can't set email address"))
          }
    )
}

We can now use these new Optionals we’ve just defined to get and set values of any ContactInfo object:

val contactInfo1 = ContactInfo.Phone(12345)
val contactInfo2 = ContactInfo.Email("[email protected]")

// Get phone number from contactInfo1
val phoneNumber1: Either[OpticFailure, Int] = ContactInfo.phoneNumber.get(contactInfo1)
// Right(12345)

// Get phone number from contactInfo2
val phoneNumber2: Either[OpticFailure, Int] = ContactInfo.phoneNumber.get(contactInfo2)
// Left(Contact info does not contain a phone number!)

// Get email address from contactInfo1
val emailAddress1: Either[OpticFailure, String] = ContactInfo.emailAddress.get(contactInfo1)
// Left(Contact info does not contain an email address!)

// Get email address from contactInfo2
val emailAddress2: Either[OpticFailure, String] = ContactInfo.emailAddress.get(contactInfo2)
// Right([email protected])





// Set new phone number to contactInfo1
val contactInfo3: Either[OpticFailure, ContactInfo] = ContactInfo.phoneNumber.set(67890)(contactInfo1)
// Right(ContactInfo.Phone(67890))

// Set new phone number to contactInfo2
val contactInfo4: Either[OpticFailure, ContactInfo] = ContactInfo.phoneNumber.set(67890)(contactInfo2)
// Left(Can't set phone number)

// Set new email address to contactInfo1
val contactInfo5: Either[OpticFailure, ContactInfo] = ContactInfo.emailAddress.set("[email protected]")(contactInfo1)
// Left(Can't set email address)

// Set new email address to contactInfo2
val contactInfo6: Either[OpticFailure, ContactInfo] = ContactInfo.emailAddress.set("[email protected]")(contactInfo2)
// Right(ContactInfo.Email("[email protected]"))

// Update phone number of contactInfo1, increasing it by 1
val phoneNumber3: Either[OpticFailure, ContactInfo] = ContactInfo.phoneNumber.update(contactInfo1)(_ + 1)
// Right(ContactInfo.Phone(12346))

// Update phone number of contactInfo2, increasing it by 1
val phoneNumber4: Either[OpticFailure, ContactInfo] = ContactInfo.phoneNumber.update(contactInfo2)(_ + 1)
// Left(Contact info does not contain a phone number!)

// Update email address from contactInfo1, setting it to an empty string
val emailAddress3: Either[OpticFailure, ContactInfo] = ContactInfo.emailAddress.update(contactInfo1)(_ => "")
// Left(Contact info does not contain an email address!)


// Update email address from contactInfo2, setting it to an empty string
val emailAddress4: Either[OpticFailure, ContactInfo] = ContactInfo.emailAddress.update(contactInfo2)(_ => "")
// Right(ContactInfo.Email(""))

Finally, ZIO Optics includes some built-in Optionals, for example:

val head  = Optional.head         // Access the head of a List
val tail  = Optional.tail         // Access the tail of a List
val myKey = Optional.key("myKey") // Access a key of a Map
val third = Optional.at(3)        // Access an index of a ZIO Chunk

Traversal

A Traversal is an Optic that accesses zero or more values in a collection, such as a ZIO Chunk. In ZIO Optics, a Traversal is represented by the following type alias:

type Traversal[S, A] = Optic[S, S, Chunk[A], OpticFailure, OpticFailure, Chunk[A], S]

The simplified signature of Optic for Traversal would therefore now be like this:

case class Traversal[S, A](
  getOptic: S => Either[OpticFailure, Chunk[A]],
  setOptic: Chunk[A] => S => Either[OpticFailure, S]
)

 

Please notice the following:

  • The GetError and SetError types of a Traversal are OpticFailure because, for example, we might be trying to get or set a value at an index that does not exist in the collection.
  • The GetWhole, SetWholeBefore, and SetWholeAfter types are the same and represent the collection S.
  • The GetPiece and SetPiece types are the same and represent the items Chunk[A] we are trying to access. This is basically what distinguishes a Traversal from other optics: The fact that it can access zero or more values instead of a single value.

Example with Traversal

ZIO Optics includes some constructors for Traversals. For instance, if we want to filter some items from a ZIO Chunk, we can use Traversal.filter. Here is an example:

import zio.Chunk
import zio.optics._

val filterEvenNumbers: Traversal[Chunk[Int], Int] =
  Traversal.filter(_ % 2 == 0)

val items = Chunk.fromIterable(1 to 10)

// Get all even numbers from items
val evenNumbers = filterEvenNumbers.get(items)
// Right(Chunk(2,4,6,8,10))
PREVIOUS
Chapter
02
NEXT
Chapter
04