Reuse code with domain-specific steps in collection pipelines

How being mindful of collection processing promotes code reuse

Author's image
Tamás Sallai
4 mins

Why extend pipelines

So far, we've only looked into how to add new functions to collection pipelines. In this post, we'll look into why such extensions might be necessary.

We've used a rather simple head function as an example, which raises the obvious question: What if a library provides all the fundamental steps? In that case, extending the pipeline with new functions is a non-issue, and all the problems we've talked about previously are non-existent.

If you only want to use generic, building-block type functions, this might be true. In that case, choose a library that provides all the functions you need.

But for complex apps, functions that are specific to the domain might be needed to promote code reuse. In that case, no library can provide them out of the box.

This post argues that even though libraries can provide all the necessary building blocks for collection processing, being conscious about extending the pipelines promotes code reuse and thus a cleaner architecture.

Domain-specific steps

In simple cases, moving the iteratee to a separate function is enough.

As an example, filter an array of users by some non-trivial condition (Try it):

const users = [{name: "user1", active: false, score: 50}, ...];
const filtered = users.filter((user) => {
	return user.active && user.score >= 50;
});

If you need this filtering in many places, refactor it to a function (Try it):

const byActiveAndPresent = (user) => {
	return user.active && user.score >= 50;
}

const usernames = users
	.filter(byActiveAndPresent)
	.map((user) => user.name);

const scores = users
	.filter(byActiveAndPresent)
	.map((user) => user.score);

Extending the pipeline

The problem is when the change itself does not fit into a filter, map, or the other functions that are provided by the pipeline.

For example, filter the non-active users and also assign an activity rating to the objects. This operation is a filter, followed by a map, so it does not fit into either of them (Try it):

const addActivityScore = (user) => {
	...
};

const processedUsers = users
	.filter(byActiveAndPresent)
	.map(addActivityScore);

Moving this to a separate function is usually done with a ([users]) => [users] signature (Try it):

const processUsers = (users) => {
	return users
		.filter(byActiveAndPresent)
		.map(addActivityScore);
}

But in this case, this function does no longer fit into the pipeline:

const activityScores =
	processUsers(users)
		.map((user) => user.activity);

Instead of a flat structure, we're back to square one.

Using functional composition

Extending Arrays is an anti-pattern, as we've seen in a previous post in this series. Implementing and using chaining comes with its own problems.

But function composition offers a solution.

As a recap, we have a flow function that operates similar to the UNIX pipe, composing the argument functions. And the map and filter functions are partially applied, i.e., they get the iteratee on the first call and the collection itself on the second one.

The above example, rewritten to use functional composition (Try it):

const processedUsers = flow(
	filter(byActiveAndPresent),
	map(addActivityScore)
)(users);

To move this to a reusable function, just extract the pipeline itself:

const processUsers = flow(
	filter(byActiveAndPresent),
	map(addActivityScore)
);

This value is itself a valid pipeline, and can be part of a larger one:

const activityScores = flow(
	processUsers,
	map((user) => user.activity)
)(users);

This way, arbitrarily deep pipelines are possible, without losing the benefits. Parts used in multiple places can be moved to a central location, promoting code reuse.

ImmutableJs's update

ImmutableJs provides a function called update that can be used to achieve the same effect. It gets the whole collection and returns a new one (Try it):

const users = Immutable.List(...);

const processUsers = (users) => {
	return users
		.filter(byActiveAndPresent)
		.map(addActivityScore);
};

const activityScores = users
	.update(processUsers)
	.map((user) => user.activity);

With update, ImmutableJs collections are easy to extend with domain-specific steps.

Conclusion

Extending a collection pipeline with custom processing steps is an everyday task in complex apps. Doing it right promotes code reuse.

The most important step is to know the libraries. If they provide a way of adding custom steps, like ImmutableJs does, use that. If not, use functional composition instead of Array#Extras for any non-trivial processing.

January 2, 2018
In this article