Skip to content

Working with monocle and optics

Ivano Pagano edited this page Jun 30, 2020 · 2 revisions

The project contains a class TezosOptics which:

Provides monocle lenses and additional "optics" for most common access and mutation patterns for Tezos type hierarchies and ADTs

This is a brief summary for anyone unfamiliar with the optics/lenses concept

The simplest optic is a Lens. The raison-d'etre of this is better handling of deeply nested fields in case classes.
The issues shows up whenever you need to read and update a value to get a copy of the original object.

This usually translates to a galore of a.copy(b = a.b.copy(c = a.b.c.copy(...)))

Well, you can imagine the fun!

A lens is a function container that wraps both a getter and a setter for an object field. Such lenses can be then composed with each other, using guess what?... a "composeLens" function. Hence you now can have

     val a: A = ... //the top object with a field to B
     val c: C = ... //a nested object type within B
     val aToCLens = aToBLens composeLens bToCLens //creates a lens A -> C by composing existing (A -> B, B -> C) lenses, like functions!

//   which allows you to
  
     val nestedC: C = aToCLens.get(a) //you pass the top-level A and immediately read the nested C
     val updatedWithC: A = aToCLens.set(c)(a) //you pass a new value for C and the object A you want to update

Now, it looks like somebody lost control using this stuff and thus you now have a whole family of lens-like things but for complex fields and other kind of "nesting", e.g.

  • Optional: a getter/setter for fields whose value might not be there
  • Prism: a selector within a sealed trait hierarchy (a.k.a. a Sum type) //E.g. you can only pick the Some value of a Option object.
  • Traversal: a getter/setter that operates on many value in the data structure (e.g. for collection-like things)
  • Iso: a bidirectional lossless conversion between two types

(Jokes apart, it happened that generalising the approach, these additional "entities" where somewhat "discovered")

For each optic type, we agree on a standard naming convention that makes each one easier to identify and meaningfully compose with each other. Assuming a field/subtype named XXX:

  • Lens: onXXX
  • Optional: whenXXX (still in doubt if this expresses well the concept or if we should stick to onXXX)
  • Prism : selectXXX
  • Traversal: acrossXXX
  • Iso: ontoXXX

We also make use of standard instances of optics for common types, e.g.

  • stdLeft/stdRight, a Prism that digs into a Left/Right value if it matches
  • ...

And standard functions to obtain optics based on certain conditions (based on type classes), e.g.

  • each will provide a Traversal for any type that is Traversable (e.g. List)
  • some will provide an Optional for an instance of Option, in the way you would expect
  • ...

There's plenty of existing optics available which often could substitute common functions too, e.g.

  • Iso between similar representations: List/Vector, NonEmptyList/NonEmptyChain/OneAnd, ...
  • Prisms for numeric down-casting BigDecimal to Int/Long, Byte to Boolean, Double to Float, ...
  • curry/uncurry, to convert functions between curried/uncurried form
  • flipped to switch two arguments of a function
  • head to access first element of a non-empty cons-list, or option variant for cons-list
  • first/second/third/... to access the nth element
  • ... more and more