Skip to content

Instantly share code, notes, and snippets.

@julien-truffaut
Last active July 7, 2022 09:31
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save julien-truffaut/08cbc9c31a8a74cda6d36dda009a0235 to your computer and use it in GitHub Desktop.
Save julien-truffaut/08cbc9c31a8a74cda6d36dda009a0235 to your computer and use it in GitHub Desktop.
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 would not be a useful abstraction. The API should make it easy to follow those principles and avoid any undesired behaviours.
  3. Focus on Scala 3. We should design the API and include features such as they are suitable for Scala 3.
  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.

// insert diagram here

Currently, users need to manually transform from one optic to another using asX methods.

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 get an Optional[Person, Email] or if we have a Lens[Invoice, List[Item]] and we want to get a Traversal[Invoice, Item]. For those common operations, we could provide a dedicated method on all optics:

optic.some.at("hello").each

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

Remove polymorphic optics #770

Standard optics work between two types A and B. There is a generalisation of optics called polymorphic optics that takes four type parameters, generally called S, T, A, and B. This generalisation let us change the type of the value targetted by an optic. For example, if you have an Option[Int] (S), you may want to transform an Int (A) into a String (B), resulting in an Option[String] (T). Polymorphic optics can express standard optics (aka monomorphic optics) as an alias, type Lens[A, B] = PolyLens[A, A, B, B].

Polymorphic optics have several issues:

  • They obfuscate the code, especially for people new to Scala or functional programming.
  • Most optics properties are defined in term of monomorphic optics (see discussion).
  • Polymorphic optics most often require explicit type annotations because type inference in Scala is eager. For example, in polySome Scala can often infer A from context (e.g. we are modifying an Option[Person]), but B depends on the value passed to set or modify. In practice, we have to define monomorphic aliases for all polymorphic optics which pollutes the interface.
def polySome[A, B]: PolyPrism[Option[A], Option[B], A, B] = ???
def some[A]: Prism[Option[A], A] = polySome[A, A]

(optic1 compose polySome compose optic2).set("foo") // fail to infer B == String

// but the following works
(optic1 compose polySome[Int, String] compose optic2).set("foo") 
(optic1 compose some compose optic2).set("foo")

Use variance in optic type parameters #771

A Getter[A, B] is equivalent to A => B. So the variance of Getter should be the same as Function1, covariant in A, the input and contravariant 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.

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. Now, Lens inherits Getter, so they both need to have the same variance!

trait Lens[A, B] extends Getter[A, B] { // variance conflict
  def set(from: A, newValue: B): A
}

It looks like we reached a dead end. However, if we look at polymorphic optics, the situation is quite different. A PolyLens[A1, A2, B1, B2] is a pair of function (get: A1 => B1, set: (A1, B2) => A2). Now, A1 - B2 are in contravariant position, and A2 - B1 are in covariant position.

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.

This issue is directly conflicting with removing polymorphic optics in #770.

0 dependency core

A project with no dependency present several advantages:

  • More modularity. Users only import what they need.
  • Independent release cycle. We would not need to release a new version every time an upstream dependency is upgraded.
  • More flexibility. We would be able to experiment more easily with plugins and language features like Scala 3.
  • Smaller footprint. I believe it is particularly significant for Scala.js.

Monocle core only depends on a core functional library, scalaz in 1.x or cats in 2.x. We expose a couple of functions using typeclasses like modifyF, 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.

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 discussion).

Assuming we find a suitable encoding for Traversal, 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 functionalities. We need to evaluate how painful it will be for users.

Rename all compose mehtods to andThen #768

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

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.

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 optics compose methods to andThen:

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).

person >>> address >>> streetNumber
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment