A lens is a convenient type allowing for both accessing and setting a value in any kind of "structure" (records, variants, arrays, etc...).
The easiest way to create a Lens is to use the make
function provided by the Optic package:
Optic.Lens.make(...)
The function takes two arguments: the first one will extract a value of type b
from a value of type a
, the second one will update the value of type a
with a new value of type b
.
Let's say you have this simple record type:
type user = {
age: int,
name: string,
}
and the corresponding foo
value:
let foo: user = {
age: 40,
name: "foo",
}
to access the age
value, one would type: ...
foo.age
... and use this syntax to update the same field:
{...foo, age: 25}
While pretty simple, the 2 syntaxes above are very hard to compose, not re-usable, and not dynamic.
First of all, we need to "make" a lens:
let ageLens = Lens.make(
user => user.age, // "getter"
(newAge, user) => {...user, age: newAge}, // "setter"
)
We can now get the age value using the lens:
ageLens.get(foo) // 24
and set the age:
// will return the whole user object with age == 25
ageLens.set(25, foo)
This the Lens type comes with many helpers, you can update a field using the modify
function:
// will return the whole user object with age incremented
ageLens->Lens.modify(age => age + 1, foo)
Lenses are highly composable, and can even be composed with Isos, Prisms, or Optionals.
Having a dedicated syntax for record get/set operations, and functions to access arrays, results, or options values, relying in different API, has some drawbacks:
- The APIs don't always work well together
- It's hard to compose and to normalize the composition
- There is no way to safely and dynamically access record values
For instance, let's add an array of skill
to our user:
type skill = {
level: int,
name: string,
}
type user = {
age: int,
name: string,
skills: array<skill>,
}
In order to access, let's say, the second skill's level, you will have to mix the different syntax as follow:
// The following code is unsafe, it can raise an index out of bound exception
let level = foo.skills[1].level
// Update
foo.skills[1] = {...foo.skills[1], level: foo.skills[1].level + 1}
Lenses, on the other hand can be composed:
let skills = ... // Define the skills lense, or generate it with the ppx
let level = ... // Define the level lense, or generate it with the ppx
let newLens = skills
->Lens.compose(Common.Array.indexExn(1))
->Lens.compose(level)
Not only is the code more consistent (and arguably more readable), but it's also more flexible, and easier to "compose".
An other big advantage of lenses over the traditional access/update syntaxes, is the highly dynamic nature of the former.
Let's say you need to dynamically access any values from a homogeneous records:
// :warning: The following code is not valid :warning:
type config = {
apiUrlTest: string,
apiUrlStaging: string,
apiUrlProduction: string,
}
let configSingleton = { ... }
// While pretty cool, this doesn't work:
let getUrl = (key: string): string => {
configSingleton[key]
}
There is actually no way to achieve this, and you must change your implementation by using a variant for instance.
Lenses, on the other hand can help you achieve this:
@lenses
type config = {
apiUrlTest: string,
apiUrlStaging: string,
apiUrlProduction: string,
}
let configSingleton = { ... }
let getUrl = (lens: Lens.t<config, string>): string => {
lens.get(configSingleton)
}
let productionUrl = getUrl(apiUrlProduction)
The above even works with heterogeneous records:
@lenses
type user = {
age: int,
name: string,
isLoggedIn: bool,
}
let user = {...}
let get = (type a, lens: Optic.Lens.t<user, a>): a => {
lens.get(user)
}
let age = get(age) // int
let name = get(name) // string
let isLoggedIn = get(isLoggedIn) // bool
While it seems very verbose to write lenses for all the types, in practice, it's actually rather lightweight. This library comes with some default lenses for most of the common structures (arrays, variants, results, options, etc...). It also comes with a @lenses
ppx.