1. First steps
2. We combine functions
3. Partial application (currying)
4. Declarative programming
5. Ruleless Notation
6. Immutability and objects
7. Immutability and arrays
8. Lenses
9. Conclusion
This post is the sixth part of a series of articles on functional programming called "Thinking in the Ramda Style".
In the fifth part, we talked about writing functions in the no-point notation style, where the main argument with the data for our function is not explicitly indicated.
At that time, we could not rewrite all our functions in a pointless style, because we did not have some of the tools needed for this. It is time to study them.
Reading object properties
Let's look again at the example of the definition of people who have the right to vote, which we considered in the fifth part :
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY const wasNaturalized = person => Boolean(person.naturalizationDate) const isOver18 = person => person.age >= 18 const isCitizen = either(wasBornInCountry, wasNaturalized) const isEligibleToVote = both(isOver18, isCitizen)
As you can see, we made isCitizen
and isEligibleToVote
non- isEligibleToVote
, but we cannot do this with the first three functions.
As we learned in the fourth part , we can make our functions more declarative through the use of equals and gte . Let's start with this:
const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY) const wasNaturalized = person => Boolean(person.naturalizationDate) const isOver18 = person => gte(person.age, 18)
To make these functions pointless, we need a way to construct a function so that we can use the person
variable at the end of an expression. The problem is that we need to access the person
properties, now we know the only way how this can be done - and it is imperative.
prop
Fortunately, Ramda once again comes to our aid. It provides a prop function for accessing properties of objects.
Using prop
, we can rewrite person.birthCountry
to prop('birthCountry', person)
. Let's do that:
const wasBornInCountry = person => equals(prop('birthCountry', person), OUR_COUNTRY) const wasNaturalized = person => Boolean(prop('naturalizationDate', person)) const isOver18 = person => gte(prop('age', person), 18)
Wow, now it looks like something much worse. But let's continue our refactoring. Let's change the order of the arguments that we pass to equals
so that prop
comes last. equals
works in the reverse order, so that we do not break anything:
const wasBornInCountry = person => equals(OUR_COUNTRY, prop('birthCountry', person)) const wasNaturalized = person => Boolean(prop('naturalizationDate', person)) const isOver18 = person => gte(prop('age', person), 18)
Next, let's use the currying, the natural property equals
and gte
, in order to create new functions to which the result of the prop
call will be applied:
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry', person)) const wasNaturalized = person => Boolean(prop('naturalizationDate', person)) const isOver18 = person => gte(__, 18)(prop('age', person))
It still looks worse, but let's continue. Let's apply the advantage of currying again for all the prop
calls:
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry')(person)) const wasNaturalized = person => Boolean(prop('naturalizationDate')(person)) const isOver18 = person => gte(__, 18)(prop('age')(person))
Again, somehow not very. But now we see a familiar pattern. All our functions have the same image f(g(person))
, and as we know from the second part , this is equivalent to compose(f, g)(person)
.
Let's apply this advantage to our code:
const wasBornInCountry = person => compose(equals(OUR_COUNTRY), prop('birthCountry'))(person) const wasNaturalized = person => compose(Boolean, prop('naturalizationDate'))(person) const isOver18 = person => compose(gte(__, 18), prop('age'))(person)
Now we got something. All our functions look like person => f(person)
. And we already know from the fifth part that we can make these functions pointless.
const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry')) const wasNaturalized = compose(Boolean, prop('naturalizationDate')) const isOver18 = compose(gte(__, 18), prop('age'))
When we started, it was not obvious that our methods did two things. They addressed the property of the object and prepared some operations with its value. This pointless-style refactoring made this very obvious.
Let's take a look at some of the other tools that Ramda provides for working with objects.
pick
Where prop
reads one property of an object and returns its value, pick reads many properties from an object and returns a new object only with them.
For example, if we need only the names and years of the people, we can use pick(['name','age'], person)
.
has
If we just want to know that our object has a property, without reading its value, we can use the has function to check its properties and also hasIn to check the prototype chain: has('name', person)
.
path
Where prop
property of an object, path goes deeper into nested objects. For example, we want to pull out the zip code from a deeper structure: path(['address','zipCode'], person)
.
Note that the path
more forgiving than prop
. path
will return undefined
if anything on the path (including the original argument) is null
or undefined
, while prop
will cause an error in such situations.
propOr / pathOr
propOr and pathOr are similar to prop
and path
combined with defaultTo
. They provide you with the ability to specify a default value for a property or path that will not be found in the object being studied.
For example, we can provide a placeholder when we do not know the name of the person: propOr('<Unnamed>, 'name', person)
. Note that unlike prop
, propOr
will not cause an error if the person
is null
or undefined
; instead, it will return the default value.
keys / values
keys returns an array containing all the names of all the known properties of the object. values will return the values of these properties. These functions can be useful when combined with the functions of iteration through collections, which we learned about in the first part .
Adding, updating and deleting properties
Now we have a lot of tools to read from objects in a declarative style, but what about making changes?
Since immutability is important to us, we do not want to change objects directly. Instead, we want to return new objects that have changed in the way we desire.
Again, the Ramda provides us with many utilities.
assoc / assocPath
When we program in an imperative style, we can set or change the name of a person through an assignment operator: person.name = 'New name'
.
In our functional, immutable world, we can instead use assoc : const updatedPerson = assoc('name', 'newName', person)
.
assoc
returns a new object with the property value added or updated, leaving the original object unchanged.
We also have an assocPath to update the attached property: const updatedPerson = assocPath(['address', 'zipCode'], '97504', person)
.
dissoc / dissocPath / omit
What about removing properties? Imperatively, we may want to say delete person.age
. In Ramda, we will use dissoc : `const updatedPerson = dissoc ('age', person)
dissocPath is about the same, but works on deeper object structures: dissocPath(['address', 'zipCode'], person)
.
We also have omit , which can remove several properties at once: const updatedPerson = omit(['age', 'birthCountry'], person)
.
Please note that pick
and omit
bit similar and complement each other very nicely. They are very convenient for whitelists (save only a certain set of properties using pick
) and blacklists (get rid of certain properties using omit
).
Now we know enough to work with objects in declarative and immutable style. Let's write the function celebrateBirthday
, which updates the age of the person on her birthday.
const nextAge = compose(inc, prop('age')) const celebrateBirthday = person => assoc('age', nextAge(person), person)
This is a very common pattern. Instead of updating the property to a new value, we actually want to change the value through applying the function to the old value, as we did here.
I don’t know a good way to write this with less duplication and in the pointless style, having those tools that we learned about earlier.
Ramda once again saves us with the evolve function. evolve
accepts an object and allows you to specify transformation functions for the properties that we want to change. Let's refactor celebrateBirthday
to use evolve
:
const celebrateBirthday = evolve({ age: inc })
This code says that we will transform the specified object (which is not displayed due to the pointless style) through creating a new object with the same properties and values, but the age
property will be obtained by applying inc
to the original value of the age
property.
evolve
can transform multiple properties at once and even at multiple levels of nesting. The transformation of an object can have the same image as the object being modified, and the evolve
will recursively pass between the structures using the transformation functions in the specified form.
Note that evolve
does not add new properties; if you specify a transformation for a property that is not found in the object being processed, evolve
simply ignore it.
I found that evolve
quickly becoming the workhorse in my applications.
Merge objects
Sometimes you need to combine two objects together. A typical case is when you have a function that takes named options and you want to combine them with the default options. Ramda provides a merge function for this purpose.
function f(a, b, options = {}) { const defaultOptions = { value: 42, local: true } const finalOptions = merge(defaultOptions, options) }
merge
returns a new object containing all the properties and values from both objects. If both objects have the same property, then the value of the second argument will be obtained.
Having this rule with a winning second argument makes it worthwhile to use merge
as a self-sufficient tool, but less meaningful in situations with conveyors. In this case, you often need to prepare a series of transformations for an object, and one of these transformations is the combination of some new property values. In this case, you want the first argument to win instead of the second.
Attempting to simply use merge(newValues)
in a pipeline will not give what we would like to receive.
For this situation, I usually create my own utility called reverseMerge
. It can be written as const reverseMerge = flip(merge)
. A call to flip
swaps the first two arguments of the function that applies to it.
merge
performs a surface merge. If the objects, when combined, have a property whose value is a subobject, then these subobjects do not merge. Ramda does not currently have the ability to "deep merge" (The original article, the translation of which I am doing, already has outdated information on this topic. Today Ramda has functions such as mergeDeepLeft , mergeDeepRight for recursive deep merging of objects, as well as other methods for merging ).
Note that merge
takes only two arguments. If you want to combine multiple objects into one, you can use mergeAll , which accepts an array of objects to combine.
Conclusion
Today we received a wonderful set of tools for working with objects in a declarative and immutable style. Now we can read, add, update, delete and transform properties in objects without changing the original objects. And we can do all these things in a style that makes it easy to combine functions with each other.
Further
Now we can work with objects in an immutable style, but what about arrays? Immunity and Arrays will tell us what to do with them.