ADT through the looking glass - lenses, prims and other optics

Algebraic data structures and invaluable in functional programming. With immutable data structures reasoning about the code is easy. Concurrency requires little effort as you don’t have to be worried about locking, mutexes and semaphores - nobody can change your data anyway. If you need to update something you can just create an updated copy and use it for now on. Except if you have a nested structure and need to update something deep, deep inside.

Getter and Setter

Let us examine some real-world scenario. We have a program, that loads values from config files, but allow to override them with parameters passed on program startup.

final case class S3Config(bucket: String, region: String)
final case class AppConfig(s3: S3Config)

object Main {
  
  def main(args: Array[String]): Unit = {
    val app = updateConfigWithArgs(
      loadConfigFromFiles,
      args
    )
    
    // use config
  }
  
  def loadConfigFromFiles: AppConfig = ???
  
  def updateConfigWithArgs(app: AppConfig,
                           args: Array[String]): AppConfig = ???
}

(We’re gonna be hard on yourselves and don’t use libraries like scopt, because this is just an example and we won’t handle complex cases).

def updateConfigWithArgs(app: AppConfig,
                         args: Array[String]): AppConfig =
  // create (n arg, n+1 arg) pairs
  // handle them as potential --arg value pair
  (args.toList zip args.toList.tail).foldLeft(app) {
    case (app, ("--bucket", bucket)) =>
      app.copy(app.s3.copy(bucket = bucket))
    case (app, ("--region", region)) =>
      app.copy(app.s3.copy(region = region))
    case (app, _) => app
  }

So, even with such a simple nesting, we have a bit of boilerplate. If there were more levels of nesting things would look nasty. Meanwhile, OOP programmers using mutable data structures would do something like:

app.s3.bucket = bucket
app.s3.region = region

or

app.s3.setBucket(bucket)
app.s3.setRegion(region)

and called it a day. It would be nice if we could have something like OOP’s setters for immutable data structures. Well, .copy is like that, but it doesn’t compose. We need something that composes. Let’s draft an interface for such setter:

trait Setter[S, A] {
  def modify(f: A => A): S => S
  def set(a: A): S => S
}

This interface covers our 2 main use cases: setting some constant value and updating a value using some function. In our example we would need 2 Setters:

val setBucket = new Setter[AppConfig, String] {
  def modify(f: String => String): AppConfig => AppConfig =
    app => app.copy(app.s3.copy(bucket = f(app.s3.bucket)))
  def set(a: String): AppConfig => AppConfig =
    modify(_ => a)
}
val setRegion = new Setter[AppConfig, String] {
  def modify(f: String => String): AppConfig => AppConfig =
    app => app.copy(app.s3.copy(region = f(app.s3.region)))
  def set(a: String): AppConfig => AppConfig =
    modify(_ => a)
}
def updateConfigWithArgs(app: AppConfig,
                         args: Array[String]): AppConfig =
  (args.toList zip args.toList.tail).foldLeft(app) {
    case (app, ("--bucket", bucket)) =>
      setBucket.set(bucket)(app)
    case (app, ("--region", region)) =>
      setRegion.set(region)(app)
    case (app, _) => app
  }

Slightly better, but we still need to create the whole trait, which itself is kind of a pain. Could we make it more composable?

trait Setter[S, A] { self =>
  def modify(f: A => A): S => S
  def set(a: A): S => S = modify(_ => a)
  
  def compose[B](setter: Setter[A, B]): Setter[S, B] =
    new Setter[S, B] {
      def modify(f: B => B): S => S =
        (setter.modify _).andThen(self.modify _)(f)
    }
}

When you combine functions f: A => B and g: B => C in Scala, you have 2 options: f andThen g: A => C or g compose f: A => C - the latter simulates mathematical gfg \cdot f, which is defined as (gf)(x)=g(f(x))(g \cdot f)(x) = g(f(x)).

I decided to use andThen (for implementation) as more intuitive, but in libraries you might expect that andThen will have another name, while compose will behave just like in functions - so don’t be surprised if the order of operands will be flipped.

Interestingly though, (A => A) => (S => S) combined with (B => B) => (A => A) into (B => B) => (S => S) behaves like compose so I should name the method just that. (Thanks to Mateusz Górski for pointing it out!)

We could even create some utility for creating setters:

object Setter {
  def apply[S, A](update: (A => A) => (S => S)): Setter[S, A] =
    new Setter[S, A] {
      def modify(f: A => A): S => S = update(f)
    }
}

and use it to define our setters:

val setS3 = Setter[AppConfig, S3Config] { f =>
  app => app.copy(s3 = f(app.s3))
}
val setBucket = Setter[S3Config, String] { f  =>
  s3 => s3.copy(bucket = f(s3.bucket))
}
val setRegion = Setter[S3Config, String] { f =>
  s3 => s3.copy(region = f(s3.region))
}

and use them:

(setS3 combine setBucket).set(bucket)(app)
(setS3 combine setRegion).set(region)(app)

Now it is composable, but still some boilerplate, we just exchanged one boilerplate into another. If we reused the setters, then it is some gain, but writing them manually is annoying.

However, there is some advantage to the way we express setting operation right now. The code is so simple and unambiguous in implementation that we could provide some macros or shapeless magic.

object Setter {
  // do you believe in magic?
  def apply[S, A](f: S => A) = macro SetterMacros.impl
}
val setS3 = Setter[AppConfig, S3Config](_.s3)
val setBucket = Setter[S3Config, String](_.bucket)
val setRegion = Setter[S3Config, String](_.region)

There are libraries that already do this (we’ll get to them later on), so, for now, let’s just assume that this boilerplate can be generated for us.

Since there is a setter, there should be a getter too.

trait Getter[S, A] { self =>
  def get(s: S): A
  
  def andThen[B](getter: Getter[A, B]): Getter[S, B] =
    new Getter[S, B] {
      def get(s: S): B =
        (self.get _).andThen(getter.get _)(s)
    }
}
object Getter {
  def apply[S, A](f: S => A): Getter[S, A] =
    new Getter[S, A] {
      def get(s: S): A = f(s)
    }
}

Normally, using getters in specific code has not many advantages. More in libraries and other examples of generic or even generated code.

If we take a look at how setter would be generated (basically passing how to get the attribute out of object), we should be able to generate getter as well. Perhaps, we could even generate something that is both getter and setter at the same time.

Getters and setters do not compose with each other. Getter+Setter and Setter+Getter are the only pairs of structures described today that do not compose.

Lens

Lenses are things we use to take a closer look at something, focus something smaller. That metaphor is why the combination of a getter and a setter is called lens:

trait Lens[S, A] { self =>
   
  def modify(f: A => A): S => S
  def set(a: A): S => S = modify(_ => a)
  def get(s: S): A
  
  def combine[B](lens: Lens[A, B]): Lens[S, B] =
    new Lens[S, B] {
      def modify(f: B => B): S => S =
        (lens.modify _).andThen(self.modify _)(f)
      def get(s: S): B =
        (self.get _).andThen(lens.get _)(s)
    }
}

The most interesting part here is compose. Look how one modify is composed by putting lens before self and in self before lens in get. It looks a little bit like a profunctor. Could we make it more explicit?

case class Functions[A, B, C, D](f1: A => B, f2: C => D) {
  
  def dimap[A0, D1](f: A0 => A)
                   (g: D => D1): Functions[A0, B, C, D1] =
    Functions(f andThen f1, f2 andThen g)
}

class Lens[S, A](
  val functions: Functions[A => A, S => S, S, A]
)
    extends Getter[S, A]
    with Setter[S, A] {
  
  def modify(f: A => A): S => S = functions.f1(f)
  def get(s: S): A = functions.f2(s)
      
  def andThen[B](lens: Lens[A, B]): Lens[S, B] =
    new Lens(functions.dimap(lens.functions.f1)
                            (lens.functions.f2))
}

Of course, it would be too messy to maintain code like that (also inefficient), so nobody implements lenses this way, but we should be aware that hey can be implemented using profunctors, and we could use some profunctor type class to compose them. (It also implies that getter could be implemented using some functor and setter using some contravariant functor).

Removing boilerplate for Lenses

How can we use lenses easily? If we want to reuse code and need something that compiles fast, we can take a look at Monocle:

import monocle.Lens
import monocle.macros.GenLens

val setS3     : Lens[AppConfig, S3Config] =
             GenLens[AppConfig](_.s3)
val setBucket : Lens[S3Config, String] =
             GenLens[S3Config](_.bucket)
val setRegion : Lens[S3Config, String] =
             GenLens[S3Config](_.region)

(setS3 composeLens setBucket).set(bucket)(app)
(setS3 composeLens setRegion).set(region)(app)

This generates the right implementation for your case classes, that you will have to compose before using. You can also try generating things in one go:

import monocle.macros.syntax.lens._

app.lens(_.s3.bucket).set(bucket)
app.lens(_.s3.region).set(region)

For ad hoc lenses usage shapeless works equally well (though it might be harsher on your compiler):

import shapeless._

lens[AppConfig].s3.bucket.set(app)(bucket)
lens[AppConfig].s3.region.set(app)(region)

Personally, I use lenses for updating configs and nested domain models.

Prism

Lenses work great when we have product types (or at least something, that could pretend to be one). But what should be done when we have sum types?

sealed trait ConfigValue extends Product with Serializable
final case class StringVal(value: String) extends ConfigValue
final case class IntVal(value: Int) extends ConfigValue

// forget about lenses
Lens[ConfigValue, StringVal] compose Lens[StringVal, String]
// they won't work!

Lenses are used to focus on some part of your data, but you cannot focus if you don’t know if the data is even there! And with sum types, you might have several possibilities to consider!

If we stuck to our optics metaphor (and we will), we could think that we need something that would do for sum type something like light dispersion does to light. A prism of a sort.

trait Prism[S, A] {
  def reverseGet(a: A): S
  def getOption(s: S): Option[A]
  def modifyOption(f: A => A): S => Option[S] =
    s => getOption(s).map(f)
}

As we can see, this basically provides us with a type-safe way of upcast (reverseGet) and downcast (getOption). We could even use it to update value inside our polymorphic structure if it happens to contain the expected subtype (modifyOption). Let’s try to implement it!

val setStringVal = new Prism[ConfigValue, StringVal] {
  def reverseGet(a: StringVal): ConfigValue = a
  def getOption(s: ConfigValue): Option[StringVal] = {
    case a: StringVal => Some(a)
    case _            => None
  }
}

We could use e.g. partial function or extractors to provide S => Option[A] mapping, and then pass A => S lifting.

object Prism {
  
  def apply[S, A](pf: PartialFunction[S, A])(f: A => S) =
    new Prism[S, A] {
      def reverseGet(a: A): S = f(a)
      def getOption(s: S): Option[A] = 
        Option(s).collect(pf)
    }
}

val setStringVal =
  Prism[ConfigValue, StringVal] {
    case a: StringVal => a
  } { a => a }

Nice! We only have one problem. StringValue is a product type, so modifying it would require lenses. We also haven’t said anything about composing prisms and lenses. So, let’s define the composition!

trait Prism[S, A] { self =>
  ...
  
  def combine[B](prism: Prism[A, B]): Prism[S, B] =
    new Prism[S, B] {
      def reverseGet(a: A): S =
        (prism.reverseGet _).andThen(self.reverseGet _)(s)
      def getOption(s: S): Option[B] =
        self.getOption(s).flatMap(prism.getOption)
    }
  
  def combine[B](prism: Lens[A, B]): Prism[S, B] =
    new Prism[S, B] {
      def reverseGet(a: B): S =
        ??? // ooops!!!
      def getOption(s: S): Option[B] =
        self.getOption(s).map(lens.get)
    }
}

Hmm, it seems we have a blocker here. How to define reverseGet in a meaningful way when we compose Prism and Lens into Prism? Well, we can’t. We also cannot compose them into Lens because get value would have to be Option[B] while the type Lens[S, B] would require B. A pity! They are quite similar and we would definitely benefit from being able to set and modify something that has coproduct in its path!

Removing boilerplate for Prisms

Before we move on just a short look at how Monocle, helps us get rid of boilerplate:

import monocle._
import monocle.macros.GenPrism

val setStringVal: Prism[ConfigValue, StringVal] =
  GenPrism[ConfigValue, StringVal]

For now, I’m now showing shapeless’ prism example. I’ll explain why in a moment.

Optional

Lenses and prisms are 2 different things. But we could define something that is some sort of compromise between Lens and Prism interfaces. We have a feeling (correct), that modify and set could be implemented for both products and coproducts. On the other hand, reverseGet (upcast) makes sense only for sum types/subtypes. get only works with products so we could use getOption always and just wrap value with Some. (To be exact: if we knew that value is always there, we would have a product without sum types involved, so we could just use Lens. If we are considering this new thing, then we have a sum type and the value might be absent).

Let’s draft the new interface! Because it’s like lenses but with support for things that might not be there, let’s call it Optional:

trait Optional[S, A] {
  def modify(f: A => A): S => S
  def set(a: A): S => S = modify(_ => a)
  def getOption(s: S): Option[A]
}

This has to compose with both Lenses and Prisms:

trait Optional[S, A] { self =>
  ...

  def combine[B](optional: Optional[A, B]): Optional[S, B] =
    new Optional[S, B] {
      def modify(f: B => B): S => S =
        (optional.modify _).andThen(self.modify _)(f)
      def getOption(s: S): Option[B] =
        self.getOption(s).flatMap(optional.getOption)
    }
  
  def combine[B](lens: Lens[A, B]): Optional[S, B] =
    new Optional[S, B] {
      def modify(f: B => B): S => S =
        (lens.modify _).andThen(self.modify _)(f)
      def getOption(s: S): Option[B] =
        self.getOption(s).map(lens.get)
    }
  
  def combine[B](prism: Prism[A, B]): Optional[S, B] =
    new Optional[S, B] {
      def modify(f: B => B): S => S =
        self.modify { a =>
          prism
            .getOption(a)
            .map(f)
            .map(prism.reverseGet)
            .getOrElse(a)
        }
      def getOption(s: S): Option[B] =
        self.getOption(s).flatMap(prism.getOption)
    }
}

Optional can also be used as a result of the composition of Lenses with Prisms or Prisms with Lenses:

trait Lens[S, A] { self =>
  ...
  
  def combine[B](prism: Prism[A, B]): Optional[S, B] =
    new Optional[S, B] {
      def modify(f: B => B): S => S =
        self.modify { a =>
          prism
            .getOption(a)
            .map(f)
            .map(prism.reverseGet)
            .getOrElse(a)
        }
      def getOption(s: S): Option[B] =
        (self.get _).andThen(prism.getOption)(s)
    }
}
trait Prism[S, A] { self =>
  ...
  
  def combine[B](lens: Lens[A, B]): Optional[S, B] =
    new Optional[S, B] {
      def modify(f: B => B): S => S =
        (lens.modify _).andThen(self.modify _)(f)
      def getOption(s: S): Option[B] =
        self.getOption(s).map(lens.get)
    }
}

With that, we are now able to compose optics describing config values.

val setStringVal: Prism[ConfigValue, StringVal]
val setValue: Lens[StringVal, String]

val composed: Optional[ConfigValue, String] =
  (setStringVal andThen setValue)
composed.set("x")(config): ConfigValue
composed.getOption(config): Option[String]

Implementations

This is a good moment to stop and take a closer look at how things are implemented in libraries. The first thing that you’ll notice, when you look into Monocle or Scalaz implementation of lenses (Monocle and Scalaz) and prisms (Monocle only - Scalaz do not implement Prisms) does not have 2 type parameters - they have 4, which are later on fixed in type alias to 2:

trait PPrism[S, T, A, B] { ... }
trait PLens[S, T, A, B] { ... }

type Prism[S, A] = PPrism[S, S, A, A]
type Lens[S, A] = PLens[S, S, A, A]

The idea here is like this: you have a S => A lens/prism. You treat it like a bifunctor [S, A], where you pass in 2 functions S => T and A => B. So modify becomes (A => B) => (S => T), reverseGet becomes B => T etc. Authors wanted to have bimap and lens/prosm operations in one data structure which is why they ended up with PLens/PPrism. Then Lens and Prism became just a special (but common) case, when bifunctor has identity as function applied to both S => T (S =:= T) and A => B (A =:= B). Optional is also implemented this way:

trait POptional[S, T, A, B] { ... }

type Optonal[S, A] = POptional[S, S, A, A]

This is quite important information if you want to read the source code and don’t get insane - in many cases fragments that are impossible to comprehend, becomes much easier to parse if you substitute T with S, B with A, try to understand the code in simpler case, and then imagine that the optics you just analyzed are also (bi)mapped.

Shapeless makes a slightly different approach to optics. While Monocle makes a strict separation between lens, prism and optional, shapeless gets rid of optional and unifies interfaces of lens and prism - actually, the only difference between Lens[S, A] and Prism[S, A] is that the former’s get returns A while the latter’s Option[A]. They also don’t introduce 4 parameter version of optics, so the whole code is much easier to read/write if you are new to the concept.

import shapeless._

prism[ConfigValue][StringVal].value.get(cfg) // Option[String]

Usage is simple - start with lens[A]/prism[A] (this is actually irrelevant, a distinction is just for readability reasons) and then use .fieldName to create Lens or [Subtype] to create Prism. It will naturally chain optics, with no extra effort.

A similar thing we have with Circe’s optics:

// example from Circe Optiocs docs
import io.circe.optics.JsonPath._

val _phoneNum = 
  root.order.customer.contactDetails.phone.string

val phoneNum: Option[String] = _phoneNum.getOption(json)

Notice, that this way you are able to e.g. migrate one JSON format into the other without having to decode into the ADT. So you could receive events/requests in the older format, check version, run all migrations from received version to the current one, and keep case classes and case objects only for the current schema.

Traversal

Optional works under the assumption, that there are 0 or 1 smaller elements we can focus on. It might be an element of a product type or subtype of a coproduct. However, it won’t be fully useful if we’ll try to focus on elements of e.g. List[Int]. A list can have any number of elements: 0, 1, 100, 1000… If we wanted to be able to get or getOption them, it wouldn’t make much sense (well, we could get head or last element as Option, but surely not all). We might want to modify them, though.

Before we define an interface that will be a stripped down Optional, we should notice one thing. When it comes to a collection, we can filter or expand it without messing up its type. We couldn’t do it with lenses (an element of a product type is not guaranteed to be a collection so we cannot filter/expand it, we can only create a new value of the same type). Same with prisms - coproduct is already defined, and we can only create a new value of some subtype (assuming, that the subtype matches the expected for which we provided handling). Finally, optional as a common part of prisms and lenses cannot support filtering nor expanding as well.

How could our new optics handle it? Well, it can act similarly to a monad - but working on a A => F[A]:

trait Traversal[S, A] {
  def modifyF[F[_]: Applicative](f: A => F[A]): S => F[S]
  
  def modify(f: A => A): S => S =
    modifyF[Id](f)
  
  def set(a: A): S => S =
    modify(_ => a)
  
  def foldMap[M: Monoid](f: A => M)(s: S): M =
    modifyF[Const[M, ?]](a => Const(f(a)))(s).getConst

  def fold(s: S)(implicit ev: Monoid[A]): A =
    foldMap(identity)(s)
}

where Const is

// Tagged type of a kind - B is a tag and
// there is no actual value of this type here.
// It is a functor when it comes to A parameter.
case class Const[A, B](getConst: A)

Example:

def listTraversal[A] = new Traversal[List[A], A] {
  def modifyF[F[_]: Applicative](
      f: A => F[A]
  ): List[A] => F[List[A]] =
    _.foldRight(F[Applicative].pure(List.empty[A])) {
      case (a, fListA) =>
        val fFun: F[A => List[A] => List[A]] = 
          F[Applicative].pure(a1 => list => a1 :: list)
        val fInt: F[Int] = f(a)
        fFun.ap(fA).ap(fListA)
    }
}
// queries database, therfore asynchronous
val getSupervisorId: EmployeeId => Task[EmployeeId]

val employeeIds: List[EmployeeId]

val supervisorIds: Task[List[EmployeeId]] = 
  listTraversal[Task]
    .modifyF(getSupervisorId)(employeeIds)
case class Customer(signed: LocalDateTime)
val customers: List[Customer]
implicit val earlierDate: Monoid[LocalDateTime] 
val earliestSignup =
  listTraversal[Customer].foldMap(_.signed)(customers)

Nice - we can use Traversal optics to do things like Task.sequence! That is, as long as you don’t change the type. If not that little details it would be very similar to Traverse type class (in a way both are concerned about the same thing, just in a different context).

Well, to be honest… you can change the type. As you might still remember, all the previous optics were defined as POptics[S, T, A, B] with 4 type parameters, where Optics[S, A] were aliases for POptics[S, S, A, A]. The same is true about Traversal:

trait PTraversal[S, T, A, B] {
  
  def modifyF[F[_]: Applicative](f: A => F[B]): S => F[T]
  
  def modify(f: A => B): S => T =
    modifyF[Id](f)
  
  def set(a: A): S => T =
    modify(_ => a)
  
  ...
}

type Traversal[S, A] = PTraversal[S, S, A, A]
def listPTraversal[A, B] =
  new PTraversal[List[A], List[B], A, B] { ... }

def fetchUser: UserId => Future[User]

val userIds: List[UserId]

val usersF: Future[List[User]] =
  listPTraversal[UserId, User].modifyF(fetchUser)(userIds)

Traversal is more generic than Optional, so it composes with everything that Optional composes. This might help us understand why it makes sense for all optics to have 4 type parameters (for S => T and A =>B mappings) - this way we can always compose them. When used standalone, such mappings aren’t obviously useful when it comes to Lens, Prism and Optional - we can always do optics’ modify and result in mapping in 2 steps, but Traversal shows when it becomes much clearer to do it in one step.

Fold

If all we want to do is folding the result with foldMap, we don’t need a whole Traversal.

trait Fold[S, A] {
  def foldMap[M: Monoid](f: A => M)(s: S): M
}

And sometimes you cannot create one when you compose optics. In particular: when you compose many optics with getter you cannot reliably get something that handles everything required of Lens/Prism/Optional/Traverse (especially, if you consider the 4 type-parameter variants). In such cases, the result of a composition can be Fold.

Getter combined with Traversal/Optional/Prism gives Fold.

Iso

So far we used optics to focus on some smaller part of a bigger whole - it was some element of a whole element (lens), some type of a sum type (prism) or uniform way to perform both of these at once. Now, we will take a look at something from a different perspective.

Mathematics define bijection as a two-way function. Formally, it has to map to all elements of its domain (function on, surjection) and each argument assigned has a different returned value (function 1-1, injection). If you can define a bijection, you can perform an operation ff and safely reverse it - it basically ensures the existence of f1f^{-1}.

But still, it just means that the data could have 2 different representations when we send it. The behavior associated with these forms might differ so e.g. if you have a bijection between char and BitSet of fixed size, you can translate back and forth. However, if we decide that e.g. combining two chars means addition modulo 256, whole adding BitSets would be and/or/xor on corresponding fields, then you cannot rely on instinct, that f(c1 + c1) = f(c1) + f(f2).

Transformation that preserves structure - acts according to that instinctive rule that f(c1 + c1) = f(c1) + f(f2) - is called homomorphism. homos - same, morph - shape, so its name literally means that keeps shape the same. You will most often see it with combination with bijection. A homomorphic bijection is called isomorphism. iso - equal - suggest how we should think of isomorphisms. If you can map every element of one set into every element of another set, these mappings are 1-1 and preserve the relations between elements - we have no real reason to treat both sets at different things at all! The fact, that when we see something different when we investigate elements in detail, becomes just a point of view. As such, we can pick whichever point of view makes things easier for us at the moment, and don’t stick to one all the time.

Example: time can be stored both in an ISO 8601 format yyyy-mm-ddThh:mm:ss.ffffff and using a number of seconds from Unix epoch (beginning of a Unix era). If you wanted to compare time instances or add some time ranges together, the representation should not matter to the result.

But the representation can pretty much affect how easy is to compute the result! Comparison of integers is much easier than parsing and interpreting strings and then performing some complex rules that would compare time zones, etc. It might be also relatively easy to convert that string to Unix epoch - at least easier than to compare two strings immediately - and then compare the integers.

We actually saw one structure, that given transformations in both ways would translate relations on one structure to relations on the other - an invariant functor. (Actually, the example of Unix epoch and DateTime is taken from Cats documentation about Invariant).

F[Long].imap[ZonedDateTime]
  (s => ZonedDateTime.from(Instant.ofEpochSecond(s)))
  (_.toEpochSecond)

Such bijection would be isomorphic and so the received F[ZonedDateTime] map would preserve properties of F[Long]. If F would be Semigroup, then we would add zoned times treating them as seconds from Unix epoch.

But with Invariant we just used the isomorphism to turn one structure into another (equivalent!) representation. The morphism itself is hidden inside and we cannot make use of it anymore. If we wanted to be able to freely move from one representation to the other, we need some other data structure. What we would want it to do?

trait Iso[S, A] { self =>
  def get(s: S): A
  def reverseGet(a: A): S
  def reverse: Iso[A, S] = new Iso[A, S] {
    def get(s: A): S = self.reverseGet(s)
    def reverseGet(a: S): A = self.get(a)
  }
}

(Just like with everything else, you can expect that Monocle with implement PIso[S, T, A, B] and have type Iso[S, A] = PIso[S, S, A, A]).

Such interface does all that we want - it translates S to A, A to S, we can change which form is default and which one is reversed. Of course, we can also compose them.

trait Iso[S, A] { self =>
  ...
  
  def combine[B](iso: Iso[A, B]): Iso[S, B] =
    new Iso[S, B] {
      def get(s: S):B =
        (self.get _).andThen(iso.get _)(s)
      def reverseGet(a: B):S =
        (iso.reverseGet _).andThen(self.reverseGet _)(a)
    }
}

It can also be combined with other optics we talked, but we’ll skip drafting all compositions here. Let’s just say that combining with:

  • Lens/Prism/Optional will produce Lens/Prism/Optional,
  • swapping the order (e.g. Setter with Iso) won’t change the result type.

If we think about it, no big surprises here.

Of all libraries mentioned so far, only Monocle implements Iso. Shapeless and Scalaz make use of isomorphism (Scalaz even has Iso), but they don’t reify them into an optics implementation composable with other optics.

Table of composition

Scalaz has only Lenses - they always compose (as long as type parameters match).

Shapeless has Lenses and Prisms, but in its implementation, there are hardly any differences between them, so they always compose.

Monocle implements whole range of optics, but their composition is a little more tricky, so I borrowed this table from the official documentation (as well as few examples before):

  Fold Getter Setter Traversal Optional Prism Lens Iso
Fold Fold Fold Fold Fold Fold Fold Fold Fold
Getter Fold Getter - Fold Fold Fold Getter Getter
Setter - - Setter Setter Setter Setter Setter Setter
Traversal Fold Fold Setter Traversal Traversal Traversal Traversal Traversal
Optional Fold Fold Setter Traversal Optional Optional Optional Optional
Prism Fold Fold Setter Traversal Optional Prism Optional Prism
Lens Fold Getter Setter Traversal Optional Optional Lens Lens
Iso Fold Getter Setter Traversal Optional Prism Lens Iso

The docs also show the relationships between optics - what could be considered a more general version of something else. Notice, that this is not an inheritance relation - while Traversal is more generic than Optional it is not its supertype. This diagram though what we can expect when we combine two optics - usually you can just pick up the most specific thing both of them implements.

Summary

In this article we learned a bit about a family of data transformations, that helps us investigate insides and modify immutable data structures. We learned, that there are many kinds of them, each of them helps us in a different situation, and almost all of them compose together.

In order to learn when and how to use them, I would recommend starting with something simple like Shapeless’ lenses and prisms - they present the easiest way of creating optics deep looking into our ADT. Once we got better (or once shapeless becomes too slow), we can use Monocle and employ its macros and syntax to handle more complex cases with as little boilerplate as possible.