Prisms & Isos
Prisms extend the capabilities of optics from merely inspecting or modifying values to constructing them. This is very useful when using sealed hierarchies or value classes.
- Prisms extend optionals to represent class hierarchies.
- Isos extend prisms (and lenses) to represent lossless conversion between types.
- One important case is given by value classes.
- To build a value use
reverseGet.
(Sealed) class hierarchies
The following is an example of User where
we have two options: a person or a company.
import arrow.optics.*
@optics sealed interface User {
companion object
}
@optics data class Person(val name: String, val age: Int): User {
companion object
}
@optics data class Company(val name: String, val country: String): User {
companion object
}
The Arrow Optics plug-in generates two optics within User, namely
User.person and User.company. These optics only focus on a value when
it has the corresponding type. This is often used to modify a value only
for a specific type in the hierarchy, leaving the rest untouched. This is
precisely what happens in the function below:
Persons get their age incremented, but Companys remains unchanged.
fun List<User>.happyBirthday() =
map { User.person.age.modify(it) { age -> age + 1 } }
Several of the types in Arrow Core fit this pattern of sealed hierarchy, and
Arrow Optics contains optics matching those. One example is Either, with
the corresponding left and right.
Constructing values
The optics we're discussing in this section provide an added feature: they can
be used to create new values in addition to inspecting or modifying existing
ones. Optionals with this power are called prisms, and this power is
available as the reverseGet operation.
For example, we can build a Left value using the corresponding prism
instead of the constructor.
fun example() {
val x = Prism.left<Int, String>().reverseGet(5)
x shouldBe Either.Left(5)
}
Isomorphisms
Prisms allow you to construct values, but still the top of the hierarchy
may have different subclasses, so access still required as optional.
For example, you can construct an Either from Right, but when you
inspect an Either, Left is also a possibility. There are some cases
when the conversion between two types is lossless: we can go back and forth
without any chance of failure on inspection. We say that there is an
isomorphism between those types; for that reason the corresponding optic
is called an iso.
For example, we can move between Option<String> and Either<Unit, String>
without loss of information. We convert from Some to Right, and from
None to Left, and vice versa. There's no loss of information because
Unit is an object, so there's a single instance which may appear in the
Left value.
You can see an iso as a prism where get always succeeds,
or as a lens which also supports the reverseGet operation.
Value classes
One important case in which lossless conversion is possible is given by value (or inline) classes, which wrap a single value as a distinct type. This kind of classes are very useful to model your domain accurately.
@optics data class Person(val name: String, val age: Age) {
companion object
}
@JvmInline @optics value class Age(val age: Int) {
companion object
}
Since isos are also lenses, you can still use the syntax from the latter to access the value contained in the class.
fun Person.happyBirthday(): Person =
Person.age.age.modify(this) { it + 1 }
fun example() {
val p = Person("me", Age(29))
p.happyBirthday().age shouldBe Age(30)
}