Optionals
Optionals allow focusing on elements that may not be present. This includes
nullable values and elements on indexed collections such as List
or Map
.
- Optionals represent potentially missing values.
- Prisms extend optionals to represent class hierarchies.
- To access the value, use
getOrNull
. - To modify the value (only if present), use
set
andmodify
.
Indexed collections
To exemplify why optionals are helpful, let's introduce a few domain classes that model a small in-memory database mapping person names to the city in which they live.
import arrow.optics.*
import arrow.optics.dsl.*
import arrow.optics.typeclasses.*
@optics data class Db(val cities: Map<String, City>) {
companion object
}
@optics data class City(val name: String, val country: String) {
companion object
}
There's a notion of elements within a map, which we refer to by their key. However, we cannot model them as lenses because we don't know whether a particular key is present in the map. Optionals come to the rescue: they are optics whose focus may not exist for a specific value.
As a result, the get
operation is replaced by getOrNull
, where null
indicates that the value is not present. The following code snippet provides
an example of that behavior using the index
provided by Arrow Optics.
val db = Db(mapOf(
"Alejandro" to City("Hilversum", "Netherlands"),
"Ambrosio" to City("Ciudad Real", "Spain")
))
fun example() {
Db.cities.index(Index.map(), "Alejandro").country.getOrNull(db) shouldBe "Netherlands"
Db.cities.index(Index.map(), "Jack").country.getOrNull(db) shouldBe null
}
One important (and sometimes surprising) behavior of optionals is that using
set
or modify
only transforms the value if it is already present. That means
that we cannot use index
to add elements to the database, only to modify
those already present.
fun example() {
val dbWithJack = Db.cities.index(Index.map(), "Jack").set(db, City("London", "UK"))
// Jack was not really added to the database
("Jack" in dbWithJack.cities) shouldBe false
}
If you want to perform a change over the collection, use modify
over the
lens that corresponds to that field.
fun example() {
val dbWithJack = Db.cities.modify(db) { it + ("Jack" to City("London", "UK")) }
// now Jack is finally in the database
("Jack" in dbWithJack.cities) shouldBe true
}
The first parameter to the index
optional represents the type of collection
you are accessing. Currently, this argument can be Index.list
, Index.map
,
Index.sequence
, or Index.string
. The choice defines the type of keys
and values expected by each operation.
Nullable types
The Arrow Optics plug-in in Arrow 1.x creates optionals for fields with nullable
types. This has sometimes led to surprises because, with an optional, you cannot
modify that value if it's null
. In Arrow 2.x, every field gives rise to a lens
instead. The old behavior is available via the notNull
extension function.
Kotlin supports the notion of nullable types,
which clearly specify when a value may be absent. The compiler prevents you from
calling a method or function that requires a non-null
value with a potentially
absent one. These checks are also in place when working with Arrow Optics;
if you have a nickname: Lens<Person, String?>
, you must account for
nullability in each potential modification.
It's also possible to turn a lens over a nullable type into an optional of the
unwrapped type using notNull
. In the example above, nickname.notNull
has the type Optional<Person, String>
(notice the lack of ?
at the end
of the second type parameter). However, you should be aware that, in the same way
as with indexed collections where you could not add or remove elements, with
notNull
, you cannot change whether the value is null
or not; only modify
it if it's already not null.