Iso

An Iso is a lossless invertible optic that defines an isomorphism between a type S and A (i.e., a data class and its properties represented by TupleN).

Isos can be seen as a pair of functions that represent an isomorphism, get, and reverseGet. So, an Iso<S, A> represents two getters: get: (S) -> A and reverseGet: (A) -> S, where S is called the source of the Iso, and A is called the focus or target of the Iso.

A simple structure Point2D is equivalent to Tuple2<Int, Int>, so we can create an Iso<Point2D, Tuple2<Int, Int>>

import arrow.*
import arrow.core.*
import arrow.optics.*

data class Point2D(val x: Int, val y: Int)

val pointIsoTuple: Iso<Point2D, Tuple2<Int, Int>> = Iso(
    get = { point -> point.x toT point.y },
    reverseGet = { tuple -> Point2D(tuple.a, tuple.b) }
)

val point = Point2D(6, 10)
point
// Point2D(x=6, y=10)
val tuple = pointIsoTuple.get(point)
tuple
// (6, 10)
pointIsoTuple.reverseGet(tuple)
// Point2D(x=6, y=10)

Given an Iso<Point2D, Tuple2<Int, Int>>, we also have an Iso<Tuple2<Int, Int>, Point2D>. Since it represents an isomorphism between equivalent structures, we can reverse it.

val reversedIso: Iso<Tuple2<Int, Int>, Point2D> = pointIsoTuple.reverse()

Using an Iso, we can modify our source S with a function that works on our focus A.

val addFive: (Tuple2<Int, Int>) -> Tuple2<Int, Int> = { tuple2 -> (tuple2.a + 5) toT (tuple2.b + 5) }
pointIsoTuple.modify(point, addFive)
// Point2D(x=11, y=15)

A function (A) -> A can be lifted to a function (S) -> S

val liftedAddFive: (Point2D) -> Point2D = pointIsoTuple.lift(addFive)
liftedAddFive(point)
// Point2D(x=11, y=15)

We can do the same with a Functor mapping.

import arrow.core.*
import arrow.core.extensions.either.functor.*

pointIsoTuple.modifyF(Either.functor(), point) {
    try { Either.right((tuple.a / 2) toT (tuple.b / 2)) } catch(e: Exception) { Either.left(e) }
}
// Either.Right(Point2D(x=3, y=5))
val liftF: (Point2D) -> EitherOf<Throwable, Point2D> = pointIsoTuple.liftF(Either.functor()) {
    try { Either.right((tuple.a / 2) toT (tuple.b / 0)) } catch(e:Throwable) { Either.left(e) }
}

liftF(point)
// Either.Left(java.lang.ArithmeticException: / by zero)

Composition

By composing Isos, we can create additional Isos without defining them. When dealing with different APIs or frameworks, we frequently run into multiple equivalent but different structures like Point2D, Tuple2, Pair, Coord, etc.

data class Coord(val xAxis: Int, val yAxis: Int)

val pairIsoCoord: Iso<Pair<Int, Int>, Coord> = Iso(
        get = { pair -> Coord(pair.first, pair.second) },
        reverseGet = { coord -> coord.xAxis to coord.yAxis }
)

val tupleIsoPair: Iso<Tuple2<Int, Int>, Pair<Int, Int>> = Iso(
        get = { tuple -> tuple.a to tuple.b },
        reverseGet = { pair -> pair.first toT pair.second }
)

By composing pointIsoTuple, pairIsoCoord, and tupleIsoPair (and/or reversing), we can use Point2D, Tuple2<Int, Int>, Pair<Int, Int>, and Coord interchangeably as we can lift functions to the required structure.

Composing an Iso with functions can also be useful for changing the input or output type of a function. The Iso<A?, Option<A>> is available in arrow-optics as nullableToOption().

val unknownCode: (String) -> String? = { value ->
    "unknown $value"
}

val nullableOptionIso: Iso<String?, Option<String>> = nullableToOption()
(unknownCode andThen nullableOptionIso::get)("Retrieve an Option")

Iso can be composed with all optics, and composing them results in the following optics:

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

Generating isos

To avoid boilerplate, Isos can be generated for a data class to TupleN with two to 10 parameters by the @optics annotation. The Iso will be generated as a extension property on the companion object val T.Companion.iso.

@optics data class Pos(val x: Int, val y: Int) {
  companion object
}
val iso: Iso<Pos, Tuple2<Int, Int>> = Pos.iso

Polymorphic Isos

When dealing with polymorphic equivalent structures, we can create polymorphic Isos allowing us to morph the type of the focus (and, as a result, the constructed type) of our PIso.

Given our previous structures Tuple2<A, B> and Pair<A, B>, we can create a polymorphic PIso that represents a get: (Tuple2<A, B>) -> Pair<A, B> and a reverseGet: (Tuple2<C, D) -> Pair<C, D>.

fun <A, B, C, D> tuple2(): PIso<Tuple2<A, B>, Pair<C, D>, Pair<A, B>, Tuple2<C, D>> = PIso(
        { tuple -> tuple.a to tuple.b },
        { tuple -> tuple.a to tuple.b }
)

PIso (defined above) can lift a reverse function of (Pair<A, B>) -> Tuple2<B, A> to a function (Tuple2<A, B>) -> Pair<B, A>.

val reverseTupleAsPair: (Tuple2<Int, String>) -> Pair<String, Int> =
        tuple2<Int, String, String, Int>().lift { intStringPair -> intStringPair.second toT intStringPair.first }
val reverse: Pair<String, Int> = reverseTupleAsPair(5 toT "five")
reverse
//(five, 5)

Laws

Arrow provides IsoLaws in the form of test cases for internal verification of lawful instances and third party apps creating their own isos.

Do you like Arrow?

Arrow Org
<