Thinking in Ramda: Immutability and Objects

This post is Part 6 of a series about functional programming called Thinking in Ramda.

In Part 5, we talked about writing our functions in “pointfree” or “tacit” style where the main data argument to our function is not explicitly shown.

At that time, we were unable to convert all of our functions to pointfree style because we were missing some tools. It’s now time to learn those tools.

Reading Object Properties

Let’s look back at the eligible voters example that we revisited in Part 5:

Eligible Voters

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’ve made isCitizen and isEligibleToVote pointfree, but we couldn’t do that with the first three functions.

As we learned in Part 4, we can make the functions more declarative using equals and gte. Let’s start there.

Using equals and gte

const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => gte(person.age, 18)

In order to make these functions pointfree, we need a way to build up a function that we can then apply to the person at the end. The problem is that we need to access properties on the person and the only way we know how to do that is imperatively.

prop

Fortunately, Ramda can help us out. It provides the prop function for accessing properties of an object.

Using prop, we can turn person.birthCountry into prop('birthCountry', person). Let’s start with that.

Using prop

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, that looks a lot worse now. But let’s keep going with the refactoring. First, let’s swap the order of the arguments we’re passing to equals so that prop comes last. equals works the same with either order, so this is safe.

Swap equals argument order

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 curried nature of equals and gte to make new functions that we apply to the result of the prop calls.

Apply Currying

const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry', person))
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(__, 18)(prop('age', person))

That’s still a bit worse, but let’s keep going anyway. Let’s take advantage of currying again with all of the prop calls.

More Currying

const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry')(person))
const wasNaturalized = person => Boolean(prop('naturalizationDate')(person))
const isOver18 = person => gte(__, 18)(prop('age')(person))

Worse again. But now we see a familiar pattern. All three of our functions have the same shape as f(g(person)), and we know from Part 2 that this is equivalent to compose(f, g)(person).

Let’s take advantage of that.

Using compose

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’re getting somewhere. Now all three functions look like person => f(person). We know from Part 5 that we can make these functions pointfree.

Finally Pointfree

const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry'))
const wasNaturalized = compose(Boolean, prop('naturalizationDate'))
const isOver18 = compose(gte(__, 18), prop('age'))

It wasn’t obvious when we started that our methods were doing two different things. They were both accessing a property of an object and performing some operation on the value of that property. This refactoring to pointfree style has made that very explicit.

Let’s look at some more tools that Ramda provides for working with objects.

pick

Where prop reads a single property from an object and returns the value, pick reads multiple properties from an object and returns a new object with just those properties.

For example, if wanted just the names and ages of a person, we could use

pick(['name', 'age'], person)

has

If we just want to know if an object has a property without reading the value, we can use has for checking own properties, and hasIn for checking up the prototype chain:

has('name', person)

path

Where prop reads a property from an object, path dives into nested objects. For example, we could access the zip code from a deeper structure as

path(['address', 'zipCode'], person)

Note that path is more forgiving than prop. path will return undefined if anything along the path (including the original argument) is null or undefined whereas prop will raise an error.

propOr / pathOr

propOr and pathOr are similar to prop and path combined with defaultTo. They let you provide a default value to use if the property or path cannot be found in the target object.

For example, we can provide a placeholder when we don’t know a person’s name:

propOr('<Unnamed>', 'name', person)

Note that unlike prop, propOr will not raise an error if person is null or undefined; it will instead return the default value.

keys / values

keys returns an array containing the names of all of the own properties in an object. values returns the values of those properties. These functions can be useful when combined with the collection iteration functions we learned about in Part 1.

Adding, Updating, and Removing Properties

We now have lots of tools for reading from objects declaratively, but what about when we want to make changes?

Since immutability is important, we don’t want to change objects directly. Instead, we want to return new objects that have been changed in the way we want.

Once again, Ramda provides a lot of help for us.

assoc / assocPath

When programming imperatively, we could set or change the name of a person with the assignment operator:

person.name = 'New name'

In our functional, immutable world we use assoc instead:

const updatedPerson = assoc('name', 'New name', person)

assoc returns a new object with the added or updated property value, leaving the original object unchanged.

There is also assocPath for updating a nested property:

const updatedPerson = assocPath(['address', 'zipcode'], '97504', person)

dissoc / dissocPath / omit

What about deleting properties? Imperatively, we might want to say

delete person.age

In Ramda, we’d use dissoc:

const updatedPerson = dissoc('age', person)

dissocPath is similar, but works deeper into the object structure:

dissocPath(['address', 'zipCode'], person)

There is also omit, which can remove several properties at once.

const updatedPerson = omit(['age', 'birthCountry'], person)

Note that pick and omit are quite similar and complement each other nicely. They’re very handy for white-listing (keep only this set of properties using pick) or black-listing (get rid of this set of properties using omit).

Transforming Properties

We now know enough to work with objects in a declarative and immutable fashion. Let’s write a function, celebrateBirthday, that updates a person’s age on their birthday.

celebrateBirthday

const nextAge = compose(inc, prop('age'))
const celebrateBirthday = person => assoc('age', nextAge(person), person)

This is a pretty common pattern. Rather than updating a property to a known new value, we really want to transform the value by applying a function to the old value as we’ve done here.

I don’t know of a good way to write this with less duplication and in pointfree style given the tools we know about.

Ramda to the rescue once more with its evolve function. I introduced evolve in using-ramda-with-redux.

evolve takes an object that specifies a transformation function for each property to be transformed. Let’s refactor celebrateBirthday to use evolve:

Using evolve

const celebrateBirthday = evolve({ age: inc })

This code is saying to evolve the target object (not shown here because of pointfree style) by making a new object with the same properties and values, but whose age is obtained by applying inc to the original value of age.

evolve can transform multiple properties at once and at multiple levels of nesting. The transformation object can have the same shape as the object being evolved and evolve will recursively traverse both structures, applying transformation functions as it goes.

Note that evolve will not add new properties; if you specify a transformation for a property that doesn’t appear in the target object, evolve will just ignore it.

I’ve found that evolve has quickly become a workhorse in my applications.

Merging Objects

Sometimes, you’ll want to merge two objects together. A common case is when you have a function that takes named options and you want to combine those options with a set of default options. Ramda provides merge for this purpose.

Function with Options

function f(a, b, options = {}) {
  const defaultOptions = { value: 42, local: true }
  const finalOptions = merge(defaultOptions, options)
}

merge returns a new object containing all of the properties and values from both objects. If both objects have the same property, the value from the second argument is used.

Having the second argument win makes sense when using merge by itself, but less so in a pipeline situation. In that case, you’re often performing a series of transformations to an object, and one of those transformations is to merge in some new property values. In this case, you want the first argument to win.

Simply using merge(newValues) in the pipeline will not do what you expect.

For this situation, I typically define a utility function called reverseMerge. It can be written as

const reverseMerge = flip(merge)

Recall that flip reverses the first two arguments of the function it is applied to.

merge performs a shallow merge. If the objects being merged both have a property whose value is a sub-object, those sub-objects will not be merged.

See also mergeDeepLeft, mergeDeepRight, mergeDeepWith, mergeDeepWithKey

Note that merge only takes two arguments. If you want to merge multiple objects into one, there is mergeAll that takes an array of the objects to be merged.

See also mergeLeft, mergeRight, mergeWith, mergeWithKey

Conclusion

This has given us a nice set of tools for working with objects in a declarative and immutable way. We can now read, add, update, delete, and transform properties in objects without changing the original objects. And we can do these things in a way that works when combining functions.

Next

Now that we can work with objects in an immutable way, what about arrays? Immutability and Arrays shows us how.