Iso

An Iso is an optic which converts elements of type S into elements of type A without loss.

Consider a case class Person with two fields:

case class Person(name: String, age: Int)

Person is equivalent to a tuple (String, Int) and a tuple (String, Int) is equivalent to Person. So we can create an Iso between Person and (String, Int) using two total functions:

  • get: Person => (String, Int)
  • reverseGet (aka apply): (String, Int) => Person
import monocle.Iso
val personToTuple = Iso[Person, (String, Int)](p => (p.name, p.age)){case (name, age) => Person(name, age)}
personToTuple.get(Person("Zoe", 25))
// res0: (String, Int) = ("Zoe", 25)
personToTuple.reverseGet(("Zoe", 25))
// res1: Person = Person(name = "Zoe", age = 25)

Or simply:

personToTuple(("Zoe", 25))
// res2: Person = Person(name = "Zoe", age = 25)

Another common use of Iso is between collection. List and Vector represent the same concept, they are both an ordered sequence of elements but they have different performance characteristics. Therefore, we can define an Iso between a List[A] and a Vector[A]:

def listToVector[A] = Iso[List[A], Vector[A]](_.toVector)(_.toList)
listToVector.get(List(1,2,3))
// res3: Vector[Int] = Vector(1, 2, 3)

We can also reverse an Iso since it defines a symmetric transformation:

def vectorToList[A] = listToVector[A].reverse

vectorToList.get(Vector(1,2,3))
// res4: List[Int] = List(1, 2, 3)

Iso are also convenient to lift methods from one type to another, for example a String can be seen as a List[Char] so we should be able to transform all functions List[Char] => List[Char] into String => String:

val stringToList = Iso[String, List[Char]](_.toList)(_.mkString(""))
stringToList.modify(_.tail)("Hello")
// res5: String = "ello"

Iso Generation

We defined several macros to simplify the generation of Iso between a case class and its Tuple equivalent. All macros are defined in a separate module (see modules).

case class MyString(s: String)
case class Foo()
case object Bar

import monocle.macros.GenIso

First of all, GenIso.apply generates an Iso for newtype i.e. case class with a single type parameter:

GenIso[MyString, String].get(MyString("Hello"))
// res6: String = "Hello"

Then, GenIso.unit generates an Iso for object or case classes with no field:

GenIso.unit[Foo]
// res7: Iso[Foo, Unit] = monocle.PIso$$anon$9@53b3b6dd
GenIso.unit[Bar.type]
// res8: Iso[Bar.type, Unit] = monocle.PIso$$anon$9@42f1e1ea

Finally, GenIso.fields is a whitebox macro which generalise GenIso.apply to all case classes:

GenIso.fields[Person].get(Person("John", 42))
// res9: (String, Int) = ("John", 42)

Be aware that whitebox macros are not supported by all IDEs.

Laws

An Iso must satisfy all properties defined in IsoLaws from the core module. You can check the validity of your own Iso using IsoTests from the law module.

In particular, an Iso must verify that get and reverseGet are inverse. This is done via roundTripOneWay and roundTripOtherWay laws:

def roundTripOneWay[S, A](i: Iso[S, A], s: S): Boolean =
  i.reverseGet(i.get(s)) == s
  
def roundTripOtherWay[S, A](i: Iso[S, A], a: A): Boolean =
  i.get(i.reverseGet(a)) == a