A Lens is an optic used to zoom inside a Product
, e.g. case class
, Tuple
, HList
or even Map
.
Lenses
have two type parameters generally called S
and A
: Lens[S, A]
where S
represents the Product
and A
an element inside of S
.
Let’s take a simple case class with two fields:
case class Address(strNumber: Int, streetName: String)
We can create a Lens[Address, Int]
which zooms from an Address
to its field strNumber
by supplying a pair of functions:
get: Address => Int
set: Int => Address => Address
import monocle.Lens
val strNumber = Lens[Address, Int](_.strNumber)(n => a => a.copy(strNumber = n))
This case is really straightforward so we automated the generation of Lenses
from case classes using a macro:
import monocle.macros.GenLens
val strNumber = GenLens[Address](_.strNumber)
Once we have a Lens
, we can use the supplied get and set functions (nothing fancy!):
val address = Address(10, "High Street")
streetNumber.get(address) should be(res0)
streetNumber.set(5)(address) should be(res1)
We can also modify
the target of Lens
with a function, this is equivalent to call get
and then set
:
streetNumber.modify(_ + 1)(address) should be(res0)
val n = streetNumber.get(address)
n should be(res1)
streetNumber.set(n + 1)(address) should be(res2)
We can push the idea even further, with modifyF
we can update the target of a Lens
in a context, cf cats.Functor
:
def neighbors(n: Int): List[Int] =
if (n > 0) List(n - 1, n + 1) else List(n + 1)
import cats.implicits._ // to get Functor[List] instance
import cats.implicits._ // to get Functor[List] instance
streetNumber.modifyF(neighbors)(address) should be(res0)
streetNumber.modifyF(neighbors)(Address(135, "High Street")) should be(res1)
This would work with any kind of Functor
and is especially useful in conjunction with asynchronous APIs, where one has the task to update a deeply nested structure with the result of an asynchronous computation:
import scala.concurrent._
import scala.concurrent.ExecutionContext.Implicits._ // to get global ExecutionContext
def updateNumber(n: Int): Future[Int] = Future.successful(n + 1)
strNumber.modifyF(updateNumber)(address)
// res9: scala.concurrent.Future[Address] = Future(<not completed>)
Most importantly, Lenses
compose together allowing to zoom deeper in a data structure:
case class Person(name: String, age: Int, address: Address)
val john = Person("John", 20, address)
val addressLens = GenLens[Person](_.address)
addressLens composeLens streetNumber).get(john) should be(res0)
(addressLens composeLens streetNumber).set(2)(john) should be(res1)
Is possible to compose few Lenses
together by using compose
:
val compose = GenLens[Person](_.name).set("Mike") compose GenLens[Person](_.age).modify(_ + 1)
compose(john) shouldBe res0
Same but with the simplified macro based syntax:
import monocle.macros.syntax.lens._
john.lens(_.name).set("Mike").lens(_.age).modify(_ + 1)
(All Setter
like optics offer set
and modify
methods that returns an EndoFunction
(i.e. S => S
) which means that we can compose modification using basic function composition.)
Sometimes you need an easy way to update Product
type inside Sum
type - for that case you can compose Prism
with Lens
by using some
:
import monocle.std.option.some
import monocle.macros.GenLens
case class B(c: Int)
case class A(b: Option[B])
val c = GenLens[B](_.c)
val b = GenLens[A](_.b)
(b composePrism some composeLens c).getOption(A(Some(B(1)))) shouldBe res0
Lens
creation is rather boiler platy but we developed a few macros to generate them automatically. All macros are defined in a separate module (see modules).
import monocle.macros.GenLens
val age = GenLens[Person](_.age)
GenLens
can also be used to generate Lens
several level deep:
GenLens[Person](_.address.streetName).set("Iffley Road")(john) should be(res0)
For those who want to push Lenses
generation even further, we created @Lenses
macro annotation which generate Lenses
for all fields of a case class. The generated Lenses
are in the companion object of the case class:
import monocle.macros.Lenses
@Lenses case class Point(x: Int, y: Int)
val p = Point(5, 3)
Point.x.get(p) shouldBe res0
Point.y.set(0)(p) shouldBe res1
You can also add a prefix to @Lenses
in order to prefix the generated Lenses
:
@Lenses("_") case class OtherPoint(x: Int, y: Int)
val op = OtherPoint(5, 3)
OtherPoint._x.get(op) shouldBe res0
A Lens
must satisfy all properties defined in LensLaws
from the core
module. You can check the validity of your own Lenses using
LensTests from the
law module.
In particular, a Lens
must respect the getSet
law which states that if you get a value A
from S
and set
it back in, the result is an object identical to the original one. A side effect of this law is that set
must only update the A
it points to, for example it cannot increment a counter or modify another value.
On the other hand, the setGet
law states that if you set
a value
, you always get
the same value back. This law guarantees that set
is actually updating a value A
inside of S
.
val streetNumber = Lens[Address, Int](_.streetNumber)(n => a => a.copy(streetNumber = n))
def getSet[S, A](l: Lens[S, A], s: S): Boolean =
l.set(l.get(s))(s) == s
def setGet[S, A](l: Lens[S, A], s: S, a: A): Boolean =
l.get(l.set(a)(s)) == a
getSet(streetNumber, address) should be(res0)
setGet(streetNumber, address, 20) should be(res1)