Exploring the Power and Possibilities of Pipe and Compose in JavaScript
If you‘re a JavaScript developer looking to up your game and write cleaner, more modular, more readable code, there are two little functions you absolutely need to know: pipe and compose.
On the surface, they‘re deceptively simple. They just take a bunch of functions and let you call them in order on some input data, threading the output of each function to the input of the next. But don‘t let that simplicity fool you – pipe and compose pack a huge punch and open up a world of possibilities for transforming how you write JavaScript.
As Eric Elliott, one of the high priests of JavaScript functional programming, puts it:
"Function composition is the process of combining two or more functions to produce a new function. Composing functions together is like snapping together a series of pipes for our data to flow through."
In other words, pipe and compose let us build complex behaviors by combining a bunch of small, focused functions – a core tenet of functional programming and good software design in general.
So whether you‘re wrangling complex data transforms, validating gnarly forms, building intricate UIs, or just trying to make your code more readable and maintainable, pipe and compose are indispensable tools to have in your belt.
A Simple Pipe and Compose Refresher
Before we dive into the myriad ways to implement pipe and compose and their real-world applications, let‘s make sure we‘re on the same page about what they actually do. Here‘s a contrived but instructive example:
const double = x => x * 2;
const square = x => x ** 2;
const addOne = x => x + 1;
double(square(addOne(3))); // 64
square(double(addOne(3))); // 256
In this toy example, we have three basic math functions that each do one thing: double a number, square a number, and add 1 to a number. To combine them, we have to call them inside each other, carefully keeping track of the order of operations. It‘s doable, but it‘s clunky and hard to read, especially if we want to change the order or add/remove steps.
Enter pipe and compose. They let us define a pipeline or sequence of operations upfront as a chain of functions, and then apply that chain to our input data. Here‘s what the above example would look like with pipe and compose:
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
const mathPipe = pipe(addOne, square, double);
const mathCompose = compose(double, square, addOne);
mathPipe(3); // 64
mathCompose(3); // 64
Aha! So much cleaner. We define our function chain upfront as mathPipe
or mathCompose
, specifying the order of operations with a simple list of functions. Then we just call our new functions on the input data (3) and let pipe/compose handle the rest. Notice that pipe applies the functions left-to-right (add, then square, then double), while compose applies them right-to-left (double, then square, then add).
Of course, these examples are trivial and contrived. Let‘s look at some meatier, real-world applications.
Real-World Pipe and Compose: Data Edition
One of the most common and compelling use cases for pipe and compose is wrangling data, especially collections (arrays and objects) that need to be mapped, filtered, sorted, transformed, and aggregated in various ways. Imagine we have an array of products:
const products = [
{ name: ‘iPad‘, category: ‘electronics‘, price: 600, rating: 4.3 },
{ name: ‘Airpods‘, category: ‘electronics‘, price: 200, rating: 3.8 },
{ name: ‘Keyboard‘, category: ‘electronics‘, price: 50, rating: 4.0 },
{ name: ‘Chair‘, category: ‘furniture‘, price: 250, rating: 4.2 },
{ name: ‘Desk‘, category: ‘furniture‘, price: 450, rating: 4.5 },
{ name: ‘Monitor‘, category: ‘electronics‘, price: 400, rating: 4.4 },
{ name: ‘Notebook‘, category: ‘office‘, price: 8, rating: 3.9 },
{ name: ‘Headphones‘, category: ‘electronics‘, price: 175, rating: 4.1 },
];
Now let‘s say our boss asks us to generate a report of the top-rated electronics products under $500. Oh, and can we make sure to convert the prices to Euros and round the ratings while we‘re at it? We could try to cram that all into one hairy function, but we‘re functional programmers now. Let‘s break it down into small, composable steps:
const electronicProducts = prods => prods.filter(p => p.category === ‘electronics‘);
const affordableProducts = prods => prods.filter(p => p.price < 500);
const convertPrice = rate => prod => ({ ...prod, price: prod.price * rate });
const roundRating = prods => prods.map(p => ({ ...p, rating: Math.round(p.rating) }));
const sortByRating = prods => prods.sort((a, b) => b.rating - a.rating);
const topN = n => prods => prods.slice(0, n);
const exchangeRate = 0.85; // Dollars to Euros
const topAffordableElectronicsInEuros = compose(
topN(3),
roundRating,
sortByRating,
convertPrice(exchangeRate),
affordableProducts,
electronicProducts
);
console.table(topAffordableElectronicsInEuros(products));
/*
┌─────────┬──────────────┬──────────────┬───────┬────────┐
│ (index) │ name │ category │ price │ rating │
├─────────┼──────────────┼──────────────┼───────┼────────┤
│ 0 │ ‘Monitor‘ │ ‘electronics‘ │ 340 │ 4 │
│ 1 │ ‘Airpods‘ │ ‘electronics‘ │ 170 │ 4 │
│ 2 │ ‘Headphones‘ │ ‘electronics‘ │ 149 │ 4 │
└─────────┴──────────────┴──────────────┴───────┴────────┘
*/
Boom! By breaking out each sub-operation into its own small function and then composing them together, we can build a complex report generator that‘s easy to read, reason about, and modify. Want to change the price cutoff or tweak the exchange rate? Just change those values and re-run. Want to add a new filter or sort? Just write the function and pop it into the pipeline.
This is the power of pipe and compose – they let us tame complex logic by breaking it into bite-sized chunks and then stitching those chunks together declaratively. And we‘re only scratching the surface here. Imagine adding functions for pagination, search, user-specific filters, analytics, local storage caching, etc. By keeping each concern separate and composable, we can build infinitely extensible and maintainable pipelines for all our data processing needs.
Composable UIs with Pipe
Another area where pipe and compose shine is in building UIs, especially complex, interactive ones with lots of moving parts. Let‘s say we‘re building a navbar component with various sub-components:
<nav>
<Logo />
<SearchBar />
<NavLinks />
<UserMenu />
</nav>
Each of these sub-components has its own state, behavior, and logic. The Logo component might fetch the logo image from a CMS. The SearchBar component might track its input state and send search queries to an API. The NavLinks and UserMenu components might check authentication state and permissions to determine what to show.
Trying to manage all that logic in one big component class or hook would quickly get unwieldy. But we can use pipe and compose to break it into manageable, composable pieces:
import { fetchLogoUrl, buildSearchHandler, checkAuth, getUserAvatar } from ‘./navUtils‘;
const enhance = compose(
withLogoUrl(fetchLogoUrl),
withSearch(buildSearchHandler),
withAuth(checkAuth),
withUserMenu(getUserAvatar)
);
const Navbar = enhance(({ logoUrl, onSearch, isAuthed, userAvatar }) =>
<nav>
<Logo url={logoUrl} />
<SearchBar onSearch={onSearch} />
<NavLinks isAuthed={isAuthed} />
<UserMenu isAuthed={isAuthed} avatar={userAvatar} />
</nav>
);
In this example, we use a series of "higher-order components" (HOCs) to wrap our Navbar component and imbue it with various superpowers. Each HOC is responsible for managing one specific aspect of the navbar logic – fetching the logo, setting up search handlers, checking auth state, fetching user data, etc.
By composing these HOCs together with compose
, we can create a souped-up Navbar component that has all the bells and whistles, without cluttering up the component itself. The Navbar just receives the props it needs and renders the appropriate sub-components.
This pattern of breaking out stateful logic and side effects into reusable HOCs (or hooks) and then composing them together is a powerful way to build complex UIs in a modular, decoupled way. And pipe and compose are the glue that hold it all together.
Performance Considerations
Now, you might be thinking: "This is all well and good, but what about performance? Won‘t all this functional composition and HOC wrapping slow things down?"
It‘s a valid concern. Every abstraction comes with some overhead, and pipe and compose are no exception. Each function call in a composition chain adds a little bit of extra work for the JavaScript engine.
But in the vast majority of cases, this overhead is negligible, especially compared to the gains in readability, maintainability, and extensibility that pipe and compose offer. Modern JavaScript engines are really, really good at optimizing function calls, and the performance difference between a piped/composed chain of functions and an inlined mega-function is usually minimal.
Of course, there are always edge cases and extreme scenarios where performance becomes critical and every millisecond counts. In those cases, you might need to break out the big guns and start hand-tuning your code, memoizing expensive computations, and even dropping down to lower-level languages like WebAssembly.
But for the other 99.9% of the time, the clarity and composability benefits of pipe and compose far outweigh the micro-performance costs. As with all things in engineering, it‘s a tradeoff, and one that I believe is well worth making in most cases.
Conclusion
Phew! We‘ve covered a lot of ground in this deep dive into pipe and compose. We‘ve seen how they can transform clunky, nested function calls into clean, declarative pipelines. We‘ve explored how they can help us wrangle complex data processing and build modular, extensible UIs. And we‘ve grappled with the tradeoffs and performance considerations of functional composition.
I hope this has given you a taste of the power and possibilities of pipe and compose and inspired you to start using them in your own code. Trust me, once you start thinking in pipelines, you‘ll never want to go back to the nested-callback abyss.
But don‘t just take my word for it. Try refactoring one of your hairy data-processing functions or gnarly React components into a composed pipeline and see how it feels. I think you‘ll be surprised at how much cleaner and more manageable your code becomes.
And if you really want to go down the functional programming rabbit hole, check out libraries like Ramda, RxJS, and Lodash/fp, which offer a whole host of powerful utilities for composing and transforming data and behavior.
So go forth and compose, my friends. May your functions be pure, your data immutable, and your pipelines ever-flowing.