Using Lenses in PureScript
I believe we all have come across interesting problems while programming and once we found the solution or an easier way to do it, we had our minds blown away. I had a similar feeling when I discovered lenses
and started using them. No, I am not talking about my glasses or eye lenses. I am talking about lenses
in Haskell or PureScript. Incase you didn't know, we use PureScript at Juspay and after looking at the code for one of our projects, someone came up with a suggestion to use lenses.
I had heard of the term, however never found a reason to use it. And now seemed like a good chance to do that.
Update: If you've read this post already then you should check out the end as I've updated the post to describe a shorter way to define lenses.
So let's start with the problem we have and how we would solve it. Let's say we have a lot of records which are wrapped in a constructor. Something like the following:
newtype Person = Person
id :: String
{ name :: String
, location :: String
,
}
instance newtypePerson :: Newtype Person _
derive
newtype Tweet = Tweet
id :: String
{ content :: String
, location :: String
,
}
instance newtypeTweet :: Newtype Tweet _ derive
And now to access the field location
in a Person
record, we might do something like this:
getLocFromPerson :: Person -> String
Person person) = person.location
getLocFromPerson (
getLocFromPerson' :: Person -> String
Person {location}) = location
getLocFromPerson' (
= Person {id: "1", name: "Luke Skywalker", location: "Ahch-To"}
personRec = getLocFromPerson personRec
location = getLocFromPerson' personRec location'
Or as we have a newtype
instance for the Person
type we could do this:
getLocFromPerson'' :: Person -> String
= unwrap >>> _.location $ person getLocFromPerson'' person
What if we want to access location
from a Tweet
type record?
getLocFromTweet :: Tweet -> String
Tweet tweet) = tweet.location
getLocFromTweet (
getLocFromTweet' :: Tweet -> String
Tweet {location}) = location
getLocFromTweet' (
getLocFromTweet'' :: Tweet -> String
= unwrap >>> _.location $ tweet getLocFromTweet'' tweet
Feels a bit redundant, doesn't it? Having to type the same thing again and again? There's a solution to this.
getLocation :: forall a b c. Newtype a { location :: c | b } => a -> c
= unwrap >>> _.location -- A getLocation
Now this is generic enough which will work on all records which have a Newtype
instance and have a location
field in the record. Also, if you notice the type signature of the function, you will find that we don't specify the type of location
which means this would work for any type of the location
field.
This is still not intuitive enough, and imagine every time having to write:
= getLocation personRecord
location' = getLocation tweetRecord location''
But imagine writing something along the lines of
= personRecord.location
location' = tweetRecord.location location''
This is how you would do in JavaScript or Python and it feels natural, right?
Here is where lenses
come in. At this point you don't need to know how they work underneath. So we will get into how to use them right away.
Basically, you create a lens for the record, the fields, with the getters and setters and then you can use the functions lens
provides for getting a field and setting a field. So how to create a lens for the Person
type and Tweet
type?
= lens (\(Person person) -> person.location)
_locationPerson Person person) newValue -> Person person {location = newValue})
(\(
= lens (\(Tweet tweet) -> tweet.location)
_locationTweet Tweet tweet) newValue -> Tweet tweet {location = newValue}) (\(
The first argument to the lens
function is the getter for the location
field and second is another function which is setter for the location
field.
There's another way to create lenses for a field rather than writing the complete getters and setters. What we do is, we create a lens for the type with the getter and setter and compose the same to get lenses for the fields.
= lens (\(Person person) -> person) (\_ -> Person)
personLens = lens (\(Tweet tweet) -> tweet) (\_ -> Tweet)
tweetLens
= prop (SProxy :: SProxy "location")
locationProp
= personLens <<< locationProp
locationPersonLens = tweetLens <<< locationProp locationTweetLens
And now you can use something along the lines of: ^.
which is an alias for viewOn
= personRecord ^. _locationPerson
locationPerson = tweetRecord ^. _locationTweet
locationTweet
-- or
= viewOn _personRecord locationPerson
locationPerson' = viewOn _tweetRecord locationTweet
locationTweet'
-- or
= view _locationPerson personRecord
locationPerson'' = view _locationTweet tweetRecord locationTweet''
Or if we want to use the lens defined using prop
it's the same.
= personRecord ^. locationPersonLens
locationPerson = tweetRecord ^. locationTweetLens
locationTweet
-- or
= viewOn _personRecord locationPersonLens
locationPerson' = viewOn _tweetRecord locationTweetLens
locationTweet'
-- or
= view locationPersonLens personRecord
locationPerson'' = view locationTweetLens tweetRecord locationTweet''
However, if you notice, you will find that we are creating a lot of lenses which are redundant. Creating a lens for the same field in different types of records is useless and goes against why we want to use lenses. So let's resuse our solution up in A
and make this generic so we can use it in our lens
definition.
_location :: forall a b c. Newtype a {location :: c | b} => Lens' a c
= lens (unwrap >>> _.location)
_location -> wrap $ (unwrap record) { location = newValue }) (\record newValue
Now, does this not look generic enough? This would work for any record which has a Newtype
instance and also has the field location
and as our type definition is generic enough this can be used for any type of field location
. And our accessor functions change to:
= personRecord ^. _location
locationPerson = tweetRecord ^. _location locationTweet
And to set a value in a record, we would usually do:
setLocPerson :: Person -> String -> Person
Person person) newLocation = Person person {location = newLocation}
setLocPerson (
setLocTweet :: Tweet -> String -> Tweet
Tweet tweet) newLocation = Tweet tweet {location = newLocation} setLocTweet (
Instead, now we can use the functionality lens
provides and use the setter function:
= set _location "Tatooine" personRecord
newPerson = set _location "Dagobah" tweetRecord
newTweet
-- or
= personRecord # _location .~ "Tatooine"
newPerson' = tweetRecord # _location .~ "Dagobah" newTweet'
So, that's it for a bit of basics on how to get started with lens
and hope you understood. And if not, let me know in the comments and I will try to clarify them out.
Update
Thanks to the functionalprogramming Slack group I came across an interesting and shorter way to define our lenses and what we did is basically the more verbose way. The shorter solution is just using helper functions which do the work of what we are doing above.
_location :: forall a b c. Newtype a {location :: c | b} => Lens' a c
= _Newtype <<< prop (SProxy :: SProxy "location") _location
And that's it. The prop
function gives us a getter and setter for the field location
and the _Newtype
function gives us an iso
which does unwrap
and wrap
for us which we were performing in our older solution.
Curious about what else lenses could do? Thomas Honeyman wrote a wonderful article on the practical approach to using lenses. Read more about it here.
References:
- Lenses by Simon Peyton Jones: Skills Matter
- John Weigley: Putting lenses to work: YouTube
- PureScript explanation of John Weigley's video by Dominick Gendill: Blog
- Functional Programming Slack: Invite link and Team
Comments
Comments powered by Disqus