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.
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:
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:
Set a new value for a field of a product type S, given a new value A and the original structure S.
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
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:
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:
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:
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
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:
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:
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.
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:
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("test1@test.com")
// 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(test1@test.com)
// 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("test2@test.com")(contactInfo1)
// Left(Can't set email address)
// Set new email address to contactInfo2
val contactInfo6: Either[OpticFailure, ContactInfo] = ContactInfo.emailAddress.set("test2@test.com")(contactInfo2)
// Right(ContactInfo.Email("test2@test.com"))
// 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
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:
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))