Why lenses can be helpful
Lenses were created because immutable types by themselves impose a problem: we can’t change data. We can only create a copy with the applied changes, and lenses are the solution. They make the whole get/set model system as a first-citizen in your language.
This post is by no means an attempt to fully explain lenses in-depth. See the following resources for more information on this topic:
This post will use the Aether F# lenses library because I prefer their way of composing and describing lenses. Other libraries available also have the same kind of functionality and composability. Moreover, things can always be extended.
Lenses in fully closed domain models
Domain models are very strict by themselves, and assignment with ‘just another value’ is tricky. Validation is key here. The initial creation of the model goes through predefined specifications, and so the assignment via lenses should go through the same specifications.
Let’s start with an existing domain model I mentioned in a previous post, where I tried to describe guests at a concert during the COVID-19 pandemic.
The scenario is: you want to inspect if a guest has tested positive for COVID-19. Since the Guest.Pass
is an optional type, and also a discriminated union, we can’t just select this. We have to pattern-match it twice before we have access to the test result. Setting a new test result is also tedious because one has to take into account the copy syntax and the optional constructor Some
.
Lenses are here to fix that and to make sure that all selections/assignments are handled the same. First of all, we have to tell Aether about our model.
Note that Prism.create
is a function to create the necessary tuple that Aether needs. Now we have everything to compose our lens and get/set our test result.
The selection of the lens pass_tested
is made to link the Guest.Pass
with the CoronaPass.Tested
. Imagine how powerful this system is in a very big model, where all you have to do is describe the links between the models that need accessing or updating. The trick is to stop the lens selection when you encounter validation, as lenses will not be able to return a Result
type — only options. Let the consumer worry about the validation and only let them ‘pass-in’ valid models. A lens for the recovery time should stop at RecoveryTime
and not provide a link to the DateTimeOffset
, so they are required to ‘pass-in’ a validated recovery time.
This system is a great example of hiding details and focusing on the domain of your application. In the next parts, special operators will be used instead of the Optic
model (get
: ^.
, set
: ^=
) and Compose (lens
: >->
, prism
: >?>
) to further focus on the values and types.
Lenses in tough DTO conversions
A second place where lenses can be a great tool is when a tedious DTO (data transfer object) needs to be converted to your beautifully described domain model. You don’t want to take in the hard-to-follow or chaotic way the DTO is described into your domain. In the context of performance or efficiency, certain choices could be made to make the DTO more easily transferable, but that should stop when the DTO enters your domain.
In this part, we’ll use a library book with a due date. A book can be lent once and renewed once, but not more. ISBN is left out to keep it simple.
Imagine that you have to communicate with a system that describes books and due dates in a very peculiar way. Here are some examples:
book: UBIK;(philip k dick)
01/02/2022
book: THIRTEEN;(richard k morgan)
12/04/2022,30/04/2022
Our DTO could be nothing more than string
. We’d do the deserialization later.
In this case, lenses provide a single way to round-trip between the two. Firstly, let us check how the due dates are formed. It’s a list of dates separated with a comma (,
). The lens for this property will be in three parts:
- select the due dates
string
- split the
string
on comma - parse each element into a
DateTimeOffset
Once we have the date model, we have the correct input for the domain model.
Epimorphisms are represented here as partial lenses. Option
s are returned by the getter and are composed by >?>
.
Parsing the book structure is a bit harder and will require us to actually parse the input. We can use FParsec to do the heavy lifting for us.
For more information on FParsec, see these beautiful guided docs of the library. With all this in place, we have fixed the ‘from and to’ from our DTO data model and domain model. Using lenses to guide us, we have a solid system that hides the hard parts and provides us with simple functionality.
Lenses in recursive types
As a last practical example, we can take a quick look at recursive types (Catamorphisms). When a type is represented as a recursion, the interaction with the type is a lot harder. Lenses can also help here as they provide us with a link towards the property of the model you want to change, without specifying how deep that property is located.
To make this as simple as possible, let’s use Scott Wlaschin’s gift example. We could have used a product system, file system, family tree… but that would point the focus on the complexity of the type rather than the flexibility of lenses.
This model will help us create a recursive structure: a chocolate wrapped in paper and boxed, for example.
Before we dive into creating lenses for this type, we should make it easy for ourselves and provide a fold for this. As this is a ‘foldable type’, we can navigate through our model in an easy fashion.
Without going into too much detail, the backwards folding function will go through the model and run a provided function on each type if finds. So, Boxed (Wrapped (Chocolate Black))
will run for us from Boxed
towards Chocolate
and have a ‘hook’ function on each level.
There are two things we can do here. First, let’s say we want to change the kind of chocolate in the gift. That would give us this lens:
If we would want to actually change the contents of the gift, we should change the kind of return type of our foldable function towards the Chocolate
type itself. This way we can change it to Candy
, for example.
Conclusion
We’ve seen a lot of different use-cases of F# lenses practically applied in a domain context. We’ve seen closed domain model applications, DTO conversions and recursive types. There are many more but these three give you an idea of what’s possible. It’s definitely abstract and complex at first, and one has to go through some mental shifts. But the result is cleaner and more understandable code. Isn’t that what we are all striving for?
Thanks so much for staying with me,
Stijn
Subscribe to our RSS feed