Monocle 3.x

julien truffaut follow

06 Jan . 6 min read

Monocle 3.x

We recently started the development of the next major version of Monocle, Monocle 3.x. In this post, I would like to explain our objectives and discuss some of the changes we intend to make.

Monocle 3.x will be a complete rewrite. It doesn't mean we will change everything, but we will question every aspect of the library: optics encoding, API, names, dependencies, etc. We defined the following objectives to help us make trade-offs in the new design:

  1. User-friendly interface. A user should be able to perform the most common actions without requiring in-depth knowledge of optics.
  2. Correctness. Optics follow certain essential principles. Those rules may not be intuitive, but without them, optics are not a useful abstraction. The API should make it easy to follow those principles and avoid any undesired behaviours.
  3. Focus on Scala 3. The next major version of Scala is coming soon, we should design the API firstly with Scala 3 in mind.
  4. Performance. Optics are slower than handwritten equivalent code. Nevertheless, we should aim to limit this performance hit as much as possible.

Here are some of the specific changes we have in mind. Everything is still on the table; please voice your opinion in Monocle gitter or issue tracker.

Inheritance between optics

There exists a fundamental inheritance relationship between optics. An Iso is a valid Lens. A Lens is a legitimate Optional, and so on. The following diagram summarises the optics hierarchy in Monocle.

Optics hierarchy

Currently, in Monocle, optics do not extend one another. Instead, we provide asX method to manually upcast optics which is inconvenient for end-users.

1
2
3
4
5
6
7
val name: Lens[Person, String] = ???

// Monocle 1.x and 2.x
val nameOptional: Optional[Person, String] = name.asOptional

// Monocle 3.x
val nameOptional: Optional[Person, String] = name

The first version of Monocle used inheritance; this was in 2014! However, we encountered some issues with compose that motivated us to "temporarily" remove inheritance. Retrospectively, it was a mistake. We should not have favoured ease of implementation over ease of utilisation.

Object API for most common optics composition

Composing optics is very similar to composing functions; you need to make the type match, e.g. composing an Optic[A, B] and an Optic[B, C], gives you an Optic[A, C]. Since optics inherit from one another, we can often compose two different types of optics; the resulting optic type will be the least upper bound (LUB) of the two types. For example, if you compose a Lens[A, B] with a Prism[B, C], you get an Optional[A, C] because Optional is the first common parent of Lens and Prism (see full table).

In practice, some of the transformations are extremely common. Say we have a Lens[Person, Option[Email]], and we want to focus into the Some part of the Option or we have a Lens[Invoice, List[Item]], and we want to go a particular index in the list. For those common operations, we can provide a dedicated method on all optics:

1
2
3
optic.some.at("hello").each

optic.composePrism(some).composeLens(at("hello")).composTraversal(each)

This feature should make the API more IDE friendly and reduce the learning curve for new users.

Use variance in optic's type parameters #771

A Getter[A, B] is equivalent to A => B. So the variance of Getter should be the same as =>, contravariant in A, the input and covariant in B, the output. If you are like me and have trouble putting your head around variance, you will find a great resource in Thinking with types.

1
2
3
trait Getter[-A, +B] {
  def get(from: A): B
}

On the other hand, a Lens[A, B] is equivalent to a pair of function (get: A => B, set: (A, B) => A). Both A and B appear in covariant and contravariant positions, which means Lens must be invariant in A and B.

1
2
3
4
trait Lens[A, B] extends Getter[A, B] { 
  // A is both an input and ouput of set
  def set(from: A, newValue: B): A 
}

Similarly, all write optics are invariant, but it turns out that their polymorphic cousin is not. For example, a PolyLens[A1, A2, B1, B2] is a pair of function (get: A1 => B1, set: (A1, B2) => A2). Now, both A1 and B2 are in contravariant position and A2 and B1 are in covariant position.

1
2
3
trait PolyLens[-A1, +A2, +B1, -B2] extends Getter[A1, B1] { 
  def set(from: A1, newValue: B2): A2
}

Thank you, Adam Fraser and John De Goes for the idea. Also, thanks to Georgi Krastev for pointing out monomorphic Lens can inherit Getter with variance.

Regrettably, polymorphic optics bring other issues. So it is unclear if we will keep them in 3.x or if Monocle will only support monomorphic optics.

0 dependency core

A project with no dependency present several advantages:

  • Modularity. End-users may want a better syntax for modifying case classes without adding cats to their code base.
  • Independent release cycle. We don't need to release a new version every time an upstream dependency is upgraded.
  • More flexibility. We can experiment more easily with plugins and language features (e.g. Scala 3) without waiting for dependencies to support them.
  • Smaller footprint. It is particularly significant for other platforms like Scala.js.

Monocle core module only depends on a one functional library, scalaz in 1.x, cats in 2.x. Monocle exposes a couple of functions using typeclasses like modifyF, Prism.below, or Traversal.fromTraverse. Still, the main reason for this dependency is the encoding of Traversal.

We use the Van Laarhoven encoding for Traversal, which means we define all functions within Traversal in terms of modifyF.

1
2
3
trait Traversal[A, B] {
  def modifyF[F[_]: Applicative](f: B => F[B])(from: A): F[A]
}

It is still unclear if we can find an alternative encoding of Traversal without a dependency on cats (see issue). Assuming we find a suitable encoding one, we can then create a cats interop module where we would define all cats specific methods and instances. Unfortunately, end-users will need an additional import to access those functionality. We need to evaluate how often this extra import will be required.

Rename all compose methods to andThen #768

Optics and functions compose in the same way; you need to make the type match a bit like in a puzzle.

1
2
def compose[A, B, C](f: B => C, g: A => B): A => C
def compose[A, B, C](f: Optic[A, B], g: Optic[B, C]): Optic[A, C]

You may have noticed that the parameters of optics composition are the inverse of function composition. Optics composition looks more like andThen than compose.

1
2
def andThen[A, B, C](f: A => B, g: B => C): A => C
def compose[A, B, C](f: Optic[A, B], g: Optic[B, C]): Optic[A, C]

Therefore, I propose to rename all the compose methods in optics to andThen:

1
2
3
def andThenLens[A, B, C](f: Lens[A, B], g: Lens[B, C]): Lens[A, C]
def andThenPrism[A, B, C](f: Prism[A, B], g: Prism[B, C]): Prism[A, C]
// ...

For those curious about the abstraction behind function and optics, it is called Category. We can also use the "standard" symbolic alias for andThen, >>> (see Compose).

1
person >>> address >>> streetNumber

Monocle needs your help

We are always looking for new contributors and active maintainers of Monocle. You don't need to be an expert in optics; we need more people to comment on our issue tracker. We created a label beginner-friendly for issues suitable for beginners. I am also available to mentor you if you want to dive in the optics world, don't hesitate to reach out to me on twitter or gitter!