Monocle is an optics library for Scala (and Scala.js) strongly inspired by Haskell Lens.
Optics are a group of purely functional abstractions to manipulate (get
, set
, modify
, …) immutable objects.
Getting started
Monocle is published to Maven Central and cross-built for Scala 2.12
and 2.13
so you can just add the following to your build:
val monocleVersion = "2.0.0" // depends on cats 2.x
libraryDependencies ++= Seq(
"com.github.julien-truffaut" %% "monocle-core" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-macro" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-law" % monocleVersion % "test"
)
If you want to use macro annotations such as @Lenses
, you will also need to include the following for Scala 2.12
:
addCompilerPlugin("org.scalamacros" %% "paradise" % "2.1.1" cross CrossVersion.full)
On Scala 2.13
, enable the compiler flag -Ymacro-annotations
instead:
scalacOptions in Global += "-Ymacro-annotations"
Motivation
Scala already provides getters and setters for case classes but modifying nested objects is verbose which makes code difficult to understand and reason about. Let’s have a look at some examples:
case class Street(number: Int, name: String)
case class Address(city: String, street: Street)
case class Company(name: String, address: Address)
case class Employee(name: String, company: Company)
Let’s say we have an employee and we need to upper case the first character of his company street name. Here is how we could write it in vanilla Scala:
val employee = Employee("john", Company("awesome inc", Address("london", Street(23, "high street"))))
employee.copy(
company = employee.company.copy(
address = employee.company.address.copy(
street = employee.company.address.street.copy(
name = employee.company.address.street.name.capitalize // luckily capitalize exists
)
)
)
)
// res0: Employee = Employee(
// name = "john",
// company = Company(
// name = "awesome inc",
// address = Address(
// city = "london",
// street = Street(number = 23, name = "High street")
// )
// )
// )
As we can see copy
is not convenient to update nested objects because we need to repeat ourselves.
Let’s see what could we do with Monocle (type annotations are only added for clarity):
import monocle.Lens
import monocle.macros.GenLens
val company : Lens[Employee, Company] = GenLens[Employee](_.company)
val address : Lens[Company , Address] = GenLens[Company](_.address)
val street : Lens[Address , Street] = GenLens[Address](_.street)
val streetName: Lens[Street , String] = GenLens[Street](_.name)
company composeLens address composeLens street composeLens streetName
composeLens
takes two Lenses
, one from A
to B
and another one from B
to C
and creates a third Lens
from A
to C
.
Therefore, after composing company
, address
, street
and name
, we obtain a Lens
from Employee
to String
(the street name).
Now we can use this Lens
issued from the composition to modify
the street name using the function capitalize
:
(company composeLens address composeLens street composeLens streetName).modify(_.capitalize)(employee)
// res2: Employee = Employee(
// name = "john",
// company = Company(
// name = "awesome inc",
// address = Address(
// city = "london",
// street = Street(number = 23, name = "High street")
// )
// )
// )
Here modify
lifts a function String => String
to a function Employee => Employee
.
It works but it would be clearer if we could zoom into the first character of a String
with a Lens
.
However, we cannot write such a Lens
because Lenses
require the field they are directed at to be mandatory.
In our case the first character of a String
is optional as a String
can be empty.
So we need another abstraction that would be a sort of partial Lens
, in Monocle it is called an Optional
.
import monocle.function.Cons.headOption // to use headOption (an optic from Cons typeclass)
(company composeLens address
composeLens street
composeLens streetName
composeOptional headOption).modify(_.toUpper)(employee)
// res3: Employee = Employee(
// name = "john",
// company = Company(
// name = "awesome inc",
// address = Address(
// city = "london",
// street = Street(number = 23, name = "High street")
// )
// )
// )
Similarly to composeLens
, composeOptional
takes two Optionals
, one from A
to B
and another from B
to C
and
creates a third Optional
from A
to C
. All Lenses
can be seen as Optionals
where the optional element to zoom into is always
present, hence composing an Optional
and a Lens
always produces an Optional
(see class diagram for full inheritance
relation between optics).
Monocle offers various functions and macros to cut the boilerplate even further, here is an example:
import monocle.macros.syntax.lens._
employee.lens(_.company.address.street.name).composeOptional(headOption).modify(_.toUpper)
// res4: Employee = Employee(
// name = "john",
// company = Company(
// name = "awesome inc",
// address = Address(
// city = "london",
// street = Street(number = 23, name = "High street")
// )
// )
// )
Please consult the documentation or the scaladoc for more details and examples.
Maintainers and contributors
Monocle is available thanks to its maintainers (people who can merge pull requests):
- Julien Truffaut - @julien-truffaut
- Ilan Godik - @NightRa
- Naoki Aoyama - @aoiroaoino
- Kenji Yoshida - @xuwei-k
- Ken Scambler - @kenbot
and its contributors (people who have pushed commits to Monocle).
Copyright and license
All code is available to you under the MIT license, available here. The design is informed by many other projects, in particular Haskell Lens.
Copyright the maintainers, 2016.