Optics are among the most rewarding tools in functional programming. You can use them to manipulate values within data structures without boilerplate -- you just need a few types and functions from an optics library like profunctor-lenses
.
Consider working with a deeply-nested data structure like this one:
type NestedData =
Maybe (Array { foo :: Tuple String (Either Int Boolean), bar :: String })
Faced with this type, ask yourself:
- What code would I write to update the deeply-nested
Int
value, given an index in the outer array? Would my code be terse, readable, and maintainable? - How many functions would it take to get, set, and modify the
Array
,foo
, orbar
values? The inner values withinfoo
? - How quickly and easily could I update my code if this type changed -- if, for example,
Maybe (Array { foo :: ... })
becameArray { foo :: Maybe ... }
? How many functions would I have to update?
If you disliked your answers, read on: profunctor optics help you manipulate any layer of even complex structures like this one with ease.
This article will teach you to be productive with profunctor lenses, even if you haven't used them before. You'll learn how to use the four most common optics -- lenses, prisms, traversals, and isos -- to solve practical problems.
If you're looking for a more thorough introduction, I recommend Brian Marick's book Lenses for the Mere Mortal.
Original article: Practical Profunctor Lenses & Optics in PureScript
Sections
- A brief demonstration of profunctor optics in action
- Cheatsheet: common types, functions, and constructors
Lens
, lens functions, constructing lenses, andAt
lensesPrism
, prism functions, and constructing prisms- Tips for composing lenses, prisms, and other optics
Traversal
, traversal functions, andIndex
traversalsIso
, iso functions, and constructing isos- Related resources & wrapping up
Profunctor Lenses & Optics In Action
Let's use our deeply-nested data type to prove the flexibility and power of profunctor optics.
type NestedData =
Maybe (Array { foo :: Tuple String (Either Int Boolean), bar :: String })
To retrieve or update the Int
value within NestedData
, given an index in the outer array, we'd likely write a pair of functions like these:
getNestedInt :: Int -> NestedData -> Maybe Int
getNestedInt index nested = case nested >>= (_ !! index) of
Nothing -> Nothing
Just { foo: Tuple _ eitherInt } -> case eitherInt of
Left int -> Just int
Right _ -> Nothing
modifyNestedInt :: Int -> (Int -> Int) -> NestedData -> NestedData
modifyNestedInt index modifyFn nested = case _ of
Nothing -> Nothing
Just array -> Array.modifyAt index modifyFoo array
where
modifyFoo record@{ foo: Tuple str eitherInt } = case eitherInt of
Left int -> record { foo = Tuple str (Left (modifyFn int)) }
Right _ -> record
We can do better. Let's replace both functions with a single optic.
Our optic will support much more than just getting or setting values, and we'll construct it from several smaller optics in a single line of code.
Let's start with those small optics. Each optic below manages a single layer of structure. Except for _foo
, they're all pre-defined in the profunctor-lenses
library (along with most other common data types and type classes in PureScript). By convention, optics are prefixed with an underscore.
-- Access the second element of a tuple
_2 :: forall a b. Lens' (Tuple a b) b
-- Access the value of a record at the label "foo"
_foo :: forall a r. Lens' { foo :: a | r } a
-- Access the value within the `Just` constructor of a `Maybe`,
-- if it exists
_Just :: forall a. Prism' (Maybe a) a
-- Access the value within the `Left` constructor of an `Either`,
-- if it exists
_Left :: forall a b. Prism' (Either a b) a
-- Access the nth index of an `Array`, if it exists
ix :: forall a. Int -> Traversal' (Array a) a
We can use the above optics to deal with a single layer of structure at a time. To work with many layers of structure, we compose optics together. Most people read this composition left-to-right, where <<<
represents moving a single layer deeper.
For example, you can read the composed optic below as "go through the Just
layer, then the Left
layer." You can also read it right-to-left: "The optic focuses on the value in a Left
, which is itself within a Just
."
_LeftWithinJust :: forall a b. Prism' (Maybe (Either a b)) a
_LeftWithinJust = _Just <<< _Left
We now know how to compose optics, so let's write one which focuses on the Int
inside our NestedData
type.
The function below produces the optic we want, given a particular index in the outer array. Notice how the composition, read left-to-right, lines up with the layers of structure in the type. _Just
lines up with Maybe
, ix
lines up with Array
, and so on.
-- the same type we've been working with, for reference
type NestedData =
Maybe (Array { foo :: Tuple String (Either Int Boolean), bar :: String })
-- This function constructs an optic which accesses the `Int` value
-- within our deeply-nested type, if it exists
_NestedInt :: Int -> Traversal' NestedData Int
_NestedInt index = _Just <<< ix index <<< _foo <<< _2 <<< _Left
With this optic in hand, we can replace both of our previous functions with simple one-liners:
getNestedInt :: Int -> NestedData -> Maybe Int
getNestedInt index = preview (_NestedInt index)
modifyNestedInt :: Int -> (Int -> Int) -> NestedData -> NestedData
modifyNestedInt index = over (_NestedInt index)
...and that's it! We now have simple, maintainable replacements for our original functions.
Profunctor lenses also help us adapt to changes in our data types. Let's handle a significant change to NestedData
:
type NestedData =
- Maybe (Array { foo :: Tuple String (Either Int Boolean), bar :: String })
+ Array { foo :: Maybe (Tuple String (Either Int Boolean)), bar :: String }
Our composition is adaptable -- unlike our original functions. When the underlying structure changes, we can simply shuffle around or replace the optics we composed. Even better, since we used functions that work with most optics (preview
and over
), we only have to update the underlying optic for our code to work.
One optic serves as the source of truth for an entire family of functions for manipulating a value within structure. Even this significant change to the NestedData
type only requires a trivial change to our code.
_NestedInt :: Int -> Traversal' NestedData Int
-_NestedInt index = _Just <<< ix index <<< _foo <<< _2 <<< _Left
+_NestedInt index = ix index <<< _foo <<< _Just <<< _2 <<< _Left
With this simple re-ordering, our optics-based getNestedInt
and modifyNestedInt
functions handle the new NestedData
type. Compare this to the effort of refactoring the original functions!
Let's recap what we've learned so far:
- Optics are a terse, flexible way to manipulate values within structure.
- Individual optics usually deal with a single layer of structure.
- Compose optics together to deal with many layers of structure, where the rightmost optic represents the deepest layer.
- Adapt to changes in data structures by adjusting the composition used to work with that structure
- Use an optic with optic functions like
preview
andover
to perform tasks like getting, setting, modifying, and more. - By convention, optics are prefixed with an underscore.
Optics are a deep, fascinating topic, but this article focuses on their practical benefits. Over the next several sections you'll learn more about how to use them to write elegant solutions to practical problems in your code.
Cheatsheet: Types, Functions, and Constructors
PureScript's flagship optics library is profunctor-lenses
, which encompasses a family of optic types, functions, and constructors. You should understand optics from each of these three angles.
View the profunctor optics cheatsheet
Setup
I wrote this article with PureScript v0.13.2, Spago v0.8.5, and the July 25, 2019 package set (psc-0.13.2-20190725). Code along by initializing a new project and installing the libraries I used:
spago init
spago install profunctor-lenses remotedata
You can start a repl with spago repl
. I recommend using a .purs-repl
file in the root of the project to automatically import the modules you need. I used this one.
I must warn you: lenses are highly general and, without type annotations, you're likely to run into obscure errors in the repl. I recommend you:
- Define new lenses with the :paste command so you can give them the same type I did in this article (use Ctrl+D to end the paste)
- Annotate the optic or result type inline if you get a type error
- Or, write optics in a module in your editor (with type annotations), and then import the module into the repl
- Review "Identifying an optic from type spewage" from Marick's Lenses for the Mere Mortal.
In real-world code, this is typically unnecessary because there is enough context for the compiler to infer all types. Now, let's turn to our first optic -- the namesake of the profunctor-lenses
library!
Lenses
Lenses are for product types like records and tuples. The type Lens' s a
means that the structure s
contains a single value of type a
. Lenses are used to get, set, or modify the value a
within structure when you know it exists.
-- This lens focuses on the second element of a tuple and is implemented
-- in the Data.Lens.Lens.Tuple module.
_2 :: forall a b. Lens' (Tuple a b) b
-- This lens focuses on the "name" field of a record; we have to construct
-- this one ourselves.
_name :: forall a r. Lens' { name :: a | r } a
_name = prop (SProxy :: SProxy "name")
I will use these to demonstrate the common lens functions view
, set
, and over
. Then, I'll show you how to construct lenses for your types by implementing the above optics from scratch.
Common Functions For Working With Lenses
Use view
with a lens Lens' s a
to get the value a
from the structure s
. Lenses always contain their focused value, so this operation will always succeed.
-- We can use our `_2` lens to retrieve the second element of a tuple
snd :: forall a b. Tuple a b -> b
snd = view _2
> view _2 (Tuple "five" 10)
10
-- We can use our `_name` lens to retrieve the value of the 'name' field
-- from a record.
getName :: forall a r. { name :: a | r } -> a
getName = view _name
> view _name { name: "Tim", age: 40 }
"Tim"
Use set
with a lens Lens' s a
and a value of type a
to overwrite the focused value a
within the structure s
.
-- We can use our `_2` lens to replace the second element of a tuple.
setSnd :: forall a b. b -> Tuple a b -> Tuple a b
setSnd = set _2
> set _2 5 (Tuple "five" 10)
Tuple "five" 5
-- We can use our `_name` lens as an alternative to record update syntax when
-- replacing the value of a field
setName :: forall a r. a -> { name :: a | r } -> { name :: a | r }
setName = set _name
> set _name "John" { name: "Tim", age: 40 }
{ name: "John", age: 40 }
Use over
with a lens Lens' s a
and a modifying function (a -> a)
to modify the value a
within s
.
-- We can use our `_2` lens to modify the second element of a tuple
modifySnd :: forall a b. (b -> b) -> Tuple a b -> Tuple a b
modifySnd = over _2
> over _2 (_ + 5) (Tuple "five" 10)
Tuple "five" 15
-- We can use our `_name` lens as an alternative to record update syntax when
-- modifying the value of a field
modifyName :: forall a r. (a -> a) -> { name :: a | r } -> { name :: a | r }
modifyName = over _name
> over _name (_ <> " Smitherson") { name: "Tim", age: 40 }
{ name: "Tim Smitherson", age: 40 }
Constructing Lenses
The lens'
constructor produces a valid lens from a getter/setter function. This getter/setter function takes the structure s
as its argument and produces a tuple containing the focused value a
(the "getter") and a function which places a
back within the structure (the "setter").
-- Note: this is a more concrete version of the type in `profunctor-lenses`
lens' :: forall s a. (s -> Tuple a (a -> s)) -> Lens' s a
We can use lens'
to construct the _2
and _name
lenses we've been using.
The first lens focuses on the second element of a tuple. That means our getter/setter function will take a tuple as its argument, the getter part of our function should retrieve the second element of the tuple, and the setter part should overwrite it.
_2 :: forall a b. Lens' (Tuple a b) b
_2 =
lens' \(Tuple first second) ->
Tuple
second -- here we get the second element
(\b -> Tuple first b) -- and here we set the second element
The second lens focuses on the "name" field of a record. Our getter/setter function will take a record containing this label as its argument. The getter should retrieve the value of the field and the setter should overwrite it, returning the record. We can accomplish both tasks with record syntax.
_name :: forall a r. Lens' { name :: a | r } a
_name = lens' \record -> Tuple record.name (\new -> record { name = new })
We now have two valid lenses, but we should improve the second. It's common to write lenses for custom record types, but lens'
has a lot of boilerplate. Fortunately, the profunctor-lenses
library has a much nicer helper function for record lenses called prop
.
The prop
function only requires the name of the field you want to focus on. The field name must be provided as a symbol proxy; if you're unfamiliar with proxies, don't worry -- you'll soon get used to the pattern. Almost all record lenses are written like these examples.
Let's construct a few record lenses, including _name
, using prop
.
_name :: forall a r. Lens' { name :: a | r } a
_name = prop (SProxy :: SProxy "name")
_age :: forall a r. Lens' { age :: a | r } a
_age = prop (SProxy :: SProxy "age")
_location :: forall a r. Lens' { location :: a | r } a
_location = prop (SProxy :: SProxy "location")
Related Classes: The At
Lens
The At
type class describes a special kind of lens used for keyed structures like maps and sets. This lens is different from the ones we've seen before; an At
lens:
- requires that the structure
s
is a keyed structure, like a map or set - must be constructed using the
at
constructor, which requires a key of the correct type for the structure - lets you get, set, and modify (like usual lenses)
- also enables you to insert and delete elements
- focuses on a value of type
Maybe a
, rather than the simplea
we've seen so far
Consider this At
lens, created using the at
constructor:
-- This lens focuses on the specific key "Tim" in a string-keyed collection
_Tim :: forall a. At s String a => Lens' s (Maybe a)
_Tim = at "Tim"
-- We can simplify the type by specializing it
_Tim :: forall a. Lens' (Map String a) (Maybe a)
We can use this At
lens with the same common functions like view
:
-- We can use our `_Tim` lens as an alternative to the `lookup` function for
-- maps and sets.
getTim :: forall a. Map String a -> Maybe a
getTim = view _Tim
> view _Tim (Map.singleton "Tim" 40)
Just 40
-- We can also replace the `update` function for maps and sets with `over`
updateTim :: forall a. (a -> a) -> Map String a -> Map String a
updateTim f = over _Tim (map f)
> over _Tim (map (_ + 20)) (Map.singleton "Tim" 40)
fromFoldable [ Tuple "Tim" 60 ]
But we can now do more than we could with an ordinary lens: we can also insert and delete values from the structure. If the lens is used with a Just a
value, then the value will be updated or inserted. If used with a Nothing
value, then the key will be removed from the structure (if it existed).
-- We can use our `_Tim` lens as an alternative to `insert` function
insertTim :: forall a. a -> Map String a -> Map String a
insertTim = set _Tim <<< Just
> set _tim (Just 45) (Map.singleton "John" 20 ])
fromFoldable [ Tuple "John" 20, Tuple "Tim" 45 ]
-- In contrast, providing a `Nothing` value will delete the key (if it exists).
deleteTim :: forall a. Map String a -> Map String a
deleteTim = set _Tim Nothing
> set _Tim Nothing (Map.singleton "Tim" 40)
fromFoldable []
We're still using common lens functions, but the At
lens has given us some extra capabilities. While normal Lens'
gives us type-independent get, set, and modify operations, the At
lens goes even further with insertion and deletion.
Prisms
Prisms are used for sum types like Maybe
and Either
. The type Prism' s a
means that the type s
might contain a value of type a
. Consider this pair of simple prisms:
-- This prism focuses on the value within the `Left` data constructor. It's
-- defined in `Data.Lens.Either`.
_Left :: forall a b. Prism' (Either a b) a
-- The `RemoteData err a` data type is often used in PureScript to represent
-- requests. It's implemented like this in the `remotedata` library:
data RemoteData err a = NotAsked | Loading | Error err | Success a
-- The library exports prisms for the sum type. This prism focuses on the value
-- within the `Success` data constructor. If you are using the `.purs-repl` file
-- for this article then you already have this prism in scope.
_Success :: forall err a. Prism' (RemoteData err a) a
I'll use these to explore the preview
and review
functions for working with prisms, and then I'll demonstrate how to construct prisms for your types with the prism'
function.
Common Functions For Working With Prisms
Use preview
with a prism Prism' s a
to retrieve a value of type a
from a structure s
, if it exists. The value will be returned as Just a
if it exists, and Nothing
otherwise.
-- Our `_Left` prism focuses on the left branch of an `Either`, which frequently
-- represents errors or failure cases.
getLeft :: forall a b. Either a b -> Maybe a
getLeft = preview _Left
> preview _Left (Left "five")
Just "five"
-- We can use our `_Success` prism to retrieve a successful result from a
-- request, if there is one.
getSuccess :: forall err a. RemoteData err a -> Maybe a
getSuccess = preview _Success
> preview _Success (Success { name: "Tim" })
Just { name: "Tim" }
I also use the helpful is
function to test whether the focused value exists in the structure. For example:
-- check if the request succeeded, specializing the result to Boolean so it
-- can be shown in the repl
> is _Success (Success { name: "Tim" }) :: Boolean
true
-- this request has not been sent yet, so it did not succeed
> is _Success NotAsked :: Boolean
false
Next, use review
with a prism Prism' s a
to produce a structure s
given a value a
. It's a little like using pure
to lift a value into an applicative or monadic context. It generalizes the idea of a data constructor; in the below cases we could also have used the Left
and Success
data constructors directly.
mkLeft :: forall a b. a -> Either a b
mkLeft = review _Left
-- create a `Left` from a `String`, specializing the right branch to a concrete
-- type so it can be shown in the repl
> (review _Left "Resource not found") :: Either String Void
Left "Resource not found"
mkSuccess :: forall err a. a -> RemoteData err a
mkSuccess = review _Success
> review _Success { name: "Tim" } :: RemoteData Void { name :: String }
Success { name: "Tim" }
Prisms can also use the lens functions we've already discussed: view
, set
, and over
. These functions behave a little differently when used with a prism instead of a lens:
-
view
must return the focused value from the structure, but the value may not exist in a prism. Therefore,view
requires that the focused valuea
has aMonoid
instance and returns the empty value if it does not exist. -
over
andset
will manipulate the focused value if it exists, but will leave the structure unchanged if it does not exist.
Let's see each of these in practice:
-- view will return the focus value when it exists
> view _Success (Success { name: "Tim" })
{ name: "Tim" }
-- or `mempty` if it does not
> view _Success NotAsked :: { name :: String }
{ name: "" }
-- set and over will manipulate the focus value if it exists
> over _Left (_ <> " Smitherson") (Left "Tim") :: Either String Void
Left "Tim Smitherson"
-- and will leave the structure unchanged if it does not; contrast this to
-- the behavior of the `At` lens
> set _Left "John" (Right 10)
Right 10
Constructing Prisms
You can write prisms for your types using the prism'
constructor. This function produces a Prism' s a
given a constructor function a -> s
and a retrieval function s -> Maybe a
. Remember that the focused value, a
, is not guaranteed to exist in a prism!
- The constructor function is usually a data constructor like
Left
orSuccess
. - The retrieval function is usually a case statement which uses pattern-matching to retrieve the focused value
a
Almost every prism definition in the wild looks the same. Let's construct the two prisms we've been using so far:
_Left :: forall a b. Prism' (Either a b) a
_Left = prism' Left case _ of
Left a -> Just a
_ -> Nothing
_Success :: forall err a. Prism' (RemoteData err a) a
_Success = prism' Success case _ of
Success a -> Just a
_ -> Nothing
Tips For Composing Optics
Composition is what makes optics so flexible. Each optic focuses on a value within a single layer of structure. You can compose them one layer at a time to drill down through many layers of structure, and if the structure changes, you can simply remove, replace, or re-order the composed optics to accommodate the change. As a general rule, I recommend that you:
- Define optics to focus on one layer of structure at a time, and compose them to work with multiple layers of structure
- Define pre-composed optics to get and set data I use often; for example, I might define
_uuid
to retrieve a user's unique ID from a largerUser
type rather than write the composition over and over. - Don't define concrete getter or setter functions; a function like
getUUID :: User -> UUID
prevents composition with other optics. Instead, define optics, and then use common functions likeview
,preview
, andover
with the optic directly.
When reading composed optics, make sure to think of the optic as describing the location of the value, or how to access it, but not a as chain of operations like getting and setting. For example, consider this composition:
_Just <<< _name <<< _2
If you read this as "get the second element of the tuple, then get the name
field of the record, then get the value within the Just
constructor," then you are reading it backward.
Instead, think of the composition as describing the location of the value: "The focus is the second element of a tuple, which is within the name
field of a record, which is within the Just
constructor of a Maybe
."
Three common compositions describe most optics:
- Compose two optics of the same type, and you'll get the same type.
- Compose a lens and a prism, and you'll get an affine traversal.
- Compose almost anything with a traversal, and you'll get a traversal.
Finally, let's turn to traversals.
Traversals
The type Traversal' s a
means that the type s
contains zero, one, or many values of a
. A traversal can refer to one of two things:
- A traversal is used for collections like lists and maps, where the optic has zero, one, or many foci.
- An affine traversal is a more general form of a lens, prism, or composed optic, which has only zero or one focus.
You have a traversal either way, but an affine traversal is more similar in its use to a prism. Affine traversals are more general than lenses or prisms, however, and can't be used with as many functions.
Traversals that operate on collections are especially useful for:
- Applying a function to every focus in the collection (like
map
) - Collapsing every focus in the collection to a single value (like
fold
)
Affine traversals are useful when you compose lenses and prisms; you'll most often use prism functions like preview
and over
with them.
-- This pre-defined optic is valid for any `Traversable`, which includes
-- arrays, lists, and maps. It's defined in Data.Lens.Traversal.
traversed :: forall t a. Traversable t => Traversal' (t a) a
-- This affine traversal is the composition of lenses and prisms we've already
-- seen. Composing a lens and a prism will produce an affine traversal.
_city :: forall a b r. Traversal' (Maybe { city :: Maybe a | r }) a
_city = _Just <<< prop (SProxy :: _ "city") <<< _Just
Common Functions For Working With Traversals
Traversals can be used with many of the same lens and prism functions we've already seen, but can also be used with library functions meant for working with collections of values. Most of the traversal-specific functions you'll use are defined in the Data.Lens.Fold
module.
Let's start with the familiar functions we've already worked with.
Be careful with view
, which is defined to only return a single value a
from a structure: traversals support zero, one, or many values of type a
. Like a prism, if the focus doesn't exist then the monoidal empty value is returned. Like a lens, if a single focus exists, it is returned. What about collections like arrays, which contain many foci? In that case, the values are concatenated together to produce a single value.
By convention I:
- use
view
with affine traversals, which have at most one focus. - use
foldOf
with traversals which may have many foci, like an array; it behaves the same asview
, but has a more intuitive name for collections.
I'll follow this convention in the below examples.
-- when no element exists, the empty value is returned
> foldOf traversed [] :: String
""
> view _city (Just { city: Nothing }) :: String
""
-- when one element exists it is returned
> foldOf traversed [ "Gjelina" ]
"Gjelina"
> view _city (Just { city: Just "Los Angeles" })
"Los Angeles"
-- when many elements exist, they are concatenated
> foldOf traversed [ "Tim", " Smitherson" ]
"Tim Smitherson"
We can continue to use set
, and over
as we did with lenses and prisms; this time, however, the modification will apply to everything focused by the traversal, not just a single value of type a
.
-- when no focus exists, the structure is unchanged
> set traversed 10 []
[]
> over _city (_ <> ", CA") Nothing
Nothing
-- when one focus exists, `set` and `over` behave exactly as they did with
-- lenses and prisms
> set traversed 10 [ 4 ]
[ 10 ]
> over _city (_ <> ", CA") (Just { city: Just "Los Angeles" })
Just { city: Just "Los Angeles, CA" }
-- when multiple foci exist, `set` and `over` will apply to every one
> set traversed 10 (Array.range 0 4)
[ 10, 10, 10, 10, 10 ]
> over traversed (\x -> x * x) (Array.range 0 4)
[ 0, 1, 4, 9, 16 ]
We can use preview
, like with prisms, but we can't use review
with a traversal. Be careful with preview
: like view
, it's designed to return at most one focus. When many foci exist, as they may in a traversal, it will return only the first one. I prefer to:
- use
preview
for affine traversals, which have at most one focus - use
firstOf
for traversals which have many foci; it behaves the same aspreview
, but has a more intuitive name for collections
I'll use that convention with our two traversals.
-- when no focus exists, `preview` and `toFirstOf` return `Nothing`
> firstOf traversed [] :: Maybe Void
Nothing
> preview _city Nothing :: Maybe Void
Nothing
-- when one focus exists, it is returned in `Just`
> firstOf traversed [ 1 ]
Just 1
> preview _city (Just { city: Just "Los Angeles" })
Just "Los Angeles"
-- when many foci exist, only the first is returned
> firstOf traversed [ 1, 2, 3, 4, 5 ]
Just 1
Among the many library functions defined in Data.Lens.Fold
, the most often used are lastOf
, which returns the last of many foci, and toListOf
, which places the foci in an list. For example:
> lastOf traversed [ 1, 2 ]
Just 2
> toListOf _city (Just { city: Just "Los Angeles" })
"Los Angeles" : Nil
Constructing Traversals
You usually don't need to construct a traversal directly, because:
- the
traversed
optic works for any member of theTraversable
type class - lenses and prisms are already traversals
- many other optics become traversals when composed together.
The wander
function helps you construct a traversal for a data type which does not conform to Traversable
, but still has parts you want to focus. It's rarely used outside the profunctor-lenses
library, but it's good to know it exists.
Related Classes: The Index
Traversal
The Index
type class describes a special kind of traversal which is used to focus on a single element within an indexed collection. This traversal is different from the ones we've seen before; an Index
traversal:
- requires that the structure
s
is indexed, like an array, list, or map - must be constructed using the
ix
constructor, which requires an index of the correct type for the structure - lets you get, set, and modify just one focus among many in the collection -- but does not let you insert or remove elements, as
At
does, which makes it well-suited for fixed-size collections
You can use all the regular traversal functions with an Index
traversal, but remember that there is only one focus. Here's a sample:
-- This index traversal focuses on the third index of a Traversable
_3 :: Index t Int a => Traversal' t a
_3 = ix 3
-- We can simplify the type by specializing to an array of strings
_3 :: Traversal' (Array String) String
The most common operations I take with an index traversal include:
- get values with
preview
- set and modify values with
set
andover
-- We can use `preview` to look up the value by its index
getIndex3 :: Array String -> Maybe String
getIndex3 = preview _3
> preview _3 [ "Preux & Proper", "Gjelina", "Sonoratown", "Republique", "Bavel" ]
Just "Republique"
-- Use `set` and `over` to modify the value (if it exists)
setIndex3 :: String -> Array String -> Array String
setIndex3 = set _3
> set _3 "Bavel" []
[]
> over _3 (_ <> "!") [ "Gjelina", "Republique", "Bavel", "Sonoratown" ]
[ "Gjelina", "Republique", "Bavel", "Sonoratown!" ]
Isos
Isos are the most constrained optic, simply enabling you to transform back and forth between two types without losing information. The type Iso' s a
means that s
and a
are isomorphic -- the two types represent the same information. They're especially useful when you need to unwrap a newtype or transform an array to a list or otherwise convert between types. This optic is also useful for backward compatibility without boilerplate.
-- The most common iso used in practice is _Newtype, which allows you to
-- unwrap or wrap a newtype, usually composed with other optics. As usual,
-- this is simplified from the type in `profunctor-lenses`. The optic is
-- defined in Data.Lens.Iso.Newtype.
_Newtype :: forall s a. Newtype s a => Iso' s a
Common Functions
Isos share similarities with lenses and prisms. If you have an iso Iso' s a
then you can use view
to produce an a
given an s
, like a lens, and you can use review
to produce an s
given an a
, like a prism.
-- `view` can act as a replacement for the `unwrap` function from the `Newtype`
-- type class
unwrap :: forall s a. Newtype s a => s -> a
unwrap = view _Newtype
-- We'll need a type annotation here
> view (_Newtype :: Iso' (Additive Int) Int) (Additive 10)
10
-- `review` can act as a replacement for the `wrap` function
wrap :: forall s a. Newtype s a => s -> a
wrap = review _Newtype
> review (_Newtype :: Iso' (Additive Int) Int) 10
Additive 10
Constructing Isos
Isos are constructed with the iso
function. For a given Iso' s a
it expects two conversion functions as arguments: one which converts an s
to an a
, and one which converts an a
to an s
. If you have written any conversion functions already, like fromFoldable
or unwrap
, then this is trivial; if not, start by writing conversion functions before constructing the iso.
-- we can re-use the `unwrap` and `wrap` functions from `Data.Newtype` to create
-- this iso
_Newtype :: forall s a. Newtype s a => Iso' s a
_Newtype = iso unwrap wrap
Wrapping up
Optics are a powerful tool. With just a few functions for constructing and using optics, you can write more elegant code to manage complex structures. As you continue using optics, consider referring back profunctor lenses cheatsheet.
If you're curious to learn more about optics, I recommend these resources:
- Lenses for the Mere Mortal, a book by Brian Marick.
-
Lens over Tea, a series of tutorials on the Haskell
lens
library by Artyom Kazak (note: this uses a different formulation of lenses, but most of the ideas are the same)
If you've been helped by other resources, please suggest them for this list on this post's discussion on the PureScript Discourse.
Top comments (0)