Skip to main content
Version: 2.x

Optics Derivation

Optics are a way of accessing and manipulating data in a functional way. They can be used to get, set, and update values in data structures, as well as to traverse and explore data.

Manual Derivation of Optics

Before we dive into auto-derivation of optics and how we can derive optics from a ZIO Schema, let's take a look at the pure optics and how we can create them manually using ZIO Optics library.

First, we should add zio-optics to our build.sbt file:

libraryDependencies += "dev.zio" %% "zio-optics" % "<version>"

Now let's define a simple data type called User and create two optics for its name and age fields:

import zio.optics._

case class User(name: String, age: Int)

val nameLens = Lens[User, String](
user => Right(user.name),
name => user => Right(user.copy(name = name))
)

val ageLens = Lens[User, Int](
user => Right(user.age),
age => user => Right(user.copy(age = age))
)

val ageAndNameLens = nameLens.zip(ageLens)

Now we can use these optics to get, set, and update values in the Person data structure:

import zio._

object Main extends ZIOAppDefault {
def run =
for {
_ <- ZIO.debug("Pure Optics")
user = User("John", 34)

updatedUser1 <- ZIO.fromEither(nameLens.setOptic("Jane")(user))
_ <- ZIO.debug(s"Name of user updated: $updatedUser1")

updatedUser2 <- ZIO.fromEither(ageLens.setOptic(32)(user))
_ <- ZIO.debug(s"Age of user updated: $updatedUser2")

updatedUser3 <- ZIO.fromEither(
ageAndNameLens.set(("Jane", 32))(User("John", 34))
)
_ <- ZIO.debug(s"Name and age of the user updated: $updatedUser3")
} yield ()
}

Automatic Derivation of Optics

ZIO Schema has a module called zio-schema-optics which provides functionalities to derive various optics from a ZIO Schema.

By having a Schema[A], we can derive optics automatically from a schema. This means that we don't have to write the optics manually, but instead, we can use the Schema#makeAccessors method which will derive the optics for us:

trait Schema[A] {
def makeAccessors(b: AccessorBuilder): Accessors[b.Lens, b.Prism, b.Traversal]
}

It takes an AccessorBuilder which is an interface of the creation of optics:

trait AccessorBuilder {
type Lens[F, S, A]
type Prism[F, S, A]
type Traversal[S, A]

def makeLens[F, S, A](
product: Schema.Record[S],
term: Schema.Field[S, A]
): Lens[F, S, A]

def makePrism[F, S, A](
sum: Schema.Enum[S],
term: Schema.Case[S, A]
): Prism[F, S, A]

def makeTraversal[S, A](
collection: Schema.Collection[S, A],
element: Schema[A]
): Traversal[S, A]
}

It has three methods for creating three types of optics:

  • Lens is an optic used to get and update values in a product type.
  • Prism is an optic used to get and update values in a sum type.
  • Traversal is an optic used to get and update values in a collection type.

Let's take a look at how we can derive optics using ZIO Schema Optics.

Installation

To be able to derive optics from a ZIO Schema, we need to add the following line to our build.sbt file:

libraryDependencies += "dev.zio" %% "zio-schema-optics" % 1.5.0

This package contains a ZioOpticsBuilder which is an implementation of the AccessorBuilder interface based on ZIO Optics library.

Now we are ready to try any of the following examples:

Examples

Lens

Now we can derive the schema for our User data type in its companion object, and then derive optics using Schema#makeAccessors method:

import zio._
import zio.schema.DeriveSchema
import zio.schema.Schema.CaseClass2
import zio.schema.optics.ZioOpticsBuilder

case class User(name: String, age: Int)

object User {
implicit val schema: CaseClass2[String, Int, User] =
DeriveSchema.gen[User].asInstanceOf[CaseClass2[String, Int, User]]

val (nameLens, ageLens) = schema.makeAccessors(ZioOpticsBuilder)
}

Based on the type of the schema, the makeAccessors method will derive the proper optics for us.

Now we can use these optics to update values in the User data structure:

object MainApp extends ZIOAppDefault {
def run = for {
_ <- ZIO.debug("Auto-derivation of Optics")
user = User("John", 42)

updatedUser1 = User.nameLens.set("Jane")(user)
_ <- ZIO.debug(s"Name of user updated: $updatedUser1")

updatedUser2 = User.ageLens.set(32)(user)
_ <- ZIO.debug(s"Age of user updated: $updatedUser2")

nameAndAgeLens = User.nameLens.zip(User.ageLens)
updatedUser3 = nameAndAgeLens.set(("Jane", 32))(user)
_ <- ZIO.debug(s"Name and age of the user updated: $updatedUser3")
} yield ()
}

Output:

Auto-derivation of Lens Optics:
Name of user updated: Right(User(Jane,42))
Age of user updated: Right(User(John,32))
Name and age of the user updated: Right(User(Jane,32))

Prism

import zio._
import zio.schema.Schema._

sealed trait Shape {
def area: Double
}

case class Circle(radius: Double) extends Shape {
val area: Double = Math.PI * radius * radius
}

case class Rectangle(width: Double, height: Double) extends Shape {
val area: Double = width * height
}

object Shape {
implicit val schema: Enum2[Circle, Rectangle, Shape] =
DeriveSchema.gen[Shape].asInstanceOf[Enum2[Circle, Rectangle, Shape]]

val (circlePrism, rectanglePrism) =
schema.makeAccessors(ZioOpticsBuilder)
}

object MainApp extends ZIOAppDefault {
def run = for {
_ <- ZIO.debug("Auto-derivation of Prism Optics")
shape = Circle(1.2)
_ <- ZIO.debug(s"Original shape: $shape")
updatedShape <- ZIO.fromEither(
Shape.rectanglePrism.setOptic(Rectangle(2.0, 3.0))(shape)
)
_ <- ZIO.debug(s"Updated shape: $updatedShape")
} yield ()

}

Output:

Auto-derivation of Prism Optics:
Original shape: Circle(1.2)
Updated shape: Rectangle(2.0,3.0)

Traversal

import zio._
import zio.optics._
import zio.schema.Schema._
import zio.schema._

object IntList {
implicit val listschema: Schema.Sequence[List[Int], Int, String] =
Sequence[List[Int], Int, String](
elementSchema = Schema[Int],
fromChunk = _.toList,
toChunk = i => Chunk.fromIterable(i),
annotations = Chunk.empty,
identity = "List"
)

val traversal: ZTraversal[List[Int], List[Int], Int, Int] =
listschema.makeAccessors(ZioOpticsBuilder)
}

object MainApp extends ZIOAppDefault {
def run = for {
_ <- ZIO.debug("Auto-derivation of Traversal Optic:")
list = List(1, 2, 3, 4, 5)
_ <- ZIO.debug(s"Original list: $list")
updatedList <- ZIO.fromEither(IntList.traversal.set(Chunk(1, 5, 7))(list))
_ <- ZIO.debug(s"Updated list: $updatedList")
} yield ()
}

Output:

Auto-derivation of Traversal Optic:
Original list: List(1, 2, 3, 4, 5)
Updated list: List(1, 5, 7, 4, 5)