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:
- User-friendly interface. A user should be able to perform the most common actions without requiring in-depth knowledge of optics.
- 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.
- Focus on Scala 3. The next major version of Scala is coming soon, we should design the API firstly with Scala 3 in mind.
- Performance. Optics are slower than handwritten equivalent code. Nevertheless, we should aim to limit this performance hit as much as possible.
Inheritance between optics
There exists a fundamental inheritance relationship between optics. An
Iso is a valid
Lens is a legitimate
Optional, and so on. The following diagram summarises the optics hierarchy in Monocle.
Currently, in Monocle, optics do not extend one another. Instead, we provide
asX method to manually upcast optics which
is inconvenient for end-users.
The first version of Monocle used inheritance; this was in 2014! However, we encountered some issues with
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[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
Lens[A, B] with a
Prism[B, C], you get an
Optional[A, C] because
Optional is the first common parent of
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
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:
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
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.
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.
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.
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
the main reason for this dependency is the encoding of
We use the Van Laarhoven encoding for
Traversal, which means we define all functions within
Traversal in terms of
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.
You may have noticed that the parameters of optics composition are the inverse of function composition. Optics composition
looks more like
Therefore, I propose to rename all the
compose methods in optics to
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!