Composing functions in TypeScript is a core concept in functional programming that combines multiple functions into a single function that can perform any number of tasks you may require.
Function composition can be implemented in many programming languages, including TypeScript. In this article, we will learn how to create typed compose
and pipe
functions in TypeScript and how to use these functions to perform function composition in TypeScript.
Jump ahead:
compose
and pipe
functions?
Array.prototype.reduce
to create a compose
functionArray.prototype.reduce
to create a pipe
functionFunction composition in TypeScript can be done by taking the output of one function and passing it as the input to another function.
This process can be repeated with multiple functions, forming a chain of functions that can be easily composed together to perform more complex tasks.
Function composition can be used to create more readable and maintainable code, as it allows you to define small, reusable functions that can be easily combined to perform larger tasks — this can help to reduce code duplication and make it easier to understand and debug your code.
There are two common approaches to function composition in functional programming: compose
and pipe
. The compose
function combines functions from right to left, while the pipe
function combines functions from left to right.
The choice of which approach to use depends on the specific needs of the task at hand. As we get into a use case, you should get an understanding of when to use either approach.
compose
and pipe
functions?The compose
and pipe
functions are higher-order functions that accept one or more functions as arguments and return a new function that combines the functionality of the input functions.
In other words, it pulls functions and makes things cleaner and easier to deal with.
compose
functionThe compose
function combines functions from right to left, so the output of each function is passed as the input to the next function in the chain.
As an example, let’s take a look at the following functions:
function add(x: number): number { return x + 5; } function multiply(x: number): number { return x * 10; } function divide(x: number): number { return x / 2; }
We can use the compose
function to create a new function that first multiplies its arguments, then adds them together, and finally divides the result by two, as demonstrated here:
function compose<T, U, V, Y>(f: (x: T) => U, g: (y: Y) => T, h: (z: V) => Y): (x: V) => U { return (x: V) => f(g(h(x))); } const composedFunction = compose( divide, add, multiply ); console.log(composedFunction(4)); // returns (4 * 10 + 5) / 2 = 22.5
The above definition for the compose
function defines four possible type parameters that we used to define the three function arguments and the return type.
This definition can easily get confusing, but we’ll fix it in the next section using the Array.prototype.reduce
method and generic type definition.
pipe
functionThe pipe
function combines functions from left to right, so the output of each function is passed as the input to the previous function in the chain.
Using the same example functions as before, we can use the pipe
function to create a new function that first divides its arguments, then adds them together, and finally multiplies the result by two. Take a look, here:
function pipe<T, U, V, Y>(f: (x: T) => U, g: (y: U) => V, h: (z: V) => Y): (x: T) => Y { return (x: T) => h(g(f(x))); } const pipedFunction = pipe( divide, add, multiply ); console.log(pipedFunction(4)); // returns ((4 / 2) + 5) * 10 = 70
Array.prototype.reduce
to create a compose
functionWe’ll use the Array.prototype.reduce
method to create and chain custom functions into a compose
function for this tutorial.
The Array.prototype.reduce()
method is a higher-order function that applies a given function to each element of an array, resulting in a single output value. The function used to reduce the array elements is called the “reducer” function, and it takes in two arguments: the “accumulator”, and the current element being processed.
The accumulator is the result of the previous call to the reducer function; it is initialized with the first element of the array or with an optional initial value, if provided.
Now, let’s apply what we know from the above definition about Array.prototype.reduce()
to create a compose
function, as shown here:
const compose = <T>(fn1: (a: T) => T, ...fns: Array<(a: T) => T>) => fns.reduce((prevFn, nextFn) => value => prevFn(nextFn(value)), fn1);
This code defines a function called compose
that takes in a list of functions (fn1
, fns
) and returns a new function that performs the composition of these functions.
The first function, fn1
, is defined as a function that takes a value of type T
and returns a value of T
.
The rest of the functions, fns
, are defined as an array of functions that also take in a value of type T
and return a value of type T
.
N.B., note the order we’re executing: the
nextFn
function comes before theprevFn
function.
If we use this new definition against the previous example, it will produce the same result, but instead, let’s use the below example to see how the functions are chained together visually:
const func1 = (v: string) => `func1(${v})`; const func2 = (v: string) => `func2(${v})`; const func3 = (v: string) => `func3(${v})`; const composedFunction = compose(func1, func2, func3); console.log(composedFunction("value")); // func1(func2(func3(value)))
Array.prototype.reduce
to create a pipe functionThe pipe function is similar to the Unix pipe operator, where the output of one command is passed as the input to the next.
We’ll also use the Array.prototype.reduce()
to create a typed pipe
function, but instead, we’ll flip the order in which the callback arguments are executed, as shown here:
const pipe = <T>(fn1: (a: T) => T, ...fns: Array<(a: T) => T>) => fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
The above code is the same as the compose
function, except we flipped the callback arguments.
We can test it using the previous example used for the compose
function, which will give us the pipe
version of the chained operation:
const pipedFunction = pipe(func1, func2, func3); console.log(pipedFunction); // func3(func2(func1(value)))
Now that we’ve demonstrated how to compose a function in TypeScript, let’s explore how to revamp the pipe
function by extending its arguments before we conclude this article.
Currently, the pipedFunction
defines only one argument, but there will be use cases where we need to process multiple pieces of input data and pass the aggregated output to the next function.
This raises the question of how we can design the pipe
function to provide such support. Let’s find out; take a look below:
const pipe = <T extends any[], U>( fn1: (...args: T) => U, ...fns: Array<(a: U) => U> ) => { const piped = fns.reduce((prevFn, nextFn) => (value: U) => nextFn(prevFn(value)), value => value); return (...args: T) => piped(fn1(...args)); };
In the above snippet, we have redefined fn1
as a function that takes in a variable number of arguments of type T
(which is a tuple of any type) and returns a value of type U
. The fns
parameter is still a rest parameter that represents an array of functions that take in a single argument of type U
and return a value of type U
.
The pipe
function returns a new function that takes in a variable number of arguments of type T
and returns a value of type U
.
This new function executes all of the functions in the pipeline in sequence, starting with fn1
and ending with the last function in fns
, while passing the output of each function as the input to the next function in the pipeline.
Let’s test the new definition by redefining the test example by supplying more than one argument to the first function:
const func1 = (v1: string, v2: string) => `func1(${v1}, ${v2}, ...)`; const pipedFunction = pipe(func1, func2, func3); console.log(pipedFunction); // func3(func2(func1(value1, value2, ...)))
You may find yourself wondering about compose
function in this regard; can we extend it too? Well, It is impossible to define the types for the compose
function in the same way as the pipe
function. This is because the types for pipe
are determined by the type of the first function passed to it, while the types for compose
are determined by the type of the last function.
However, defining the types for compose
in this way would require using rest arguments at the beginning of the argument list, which is currently not supported.
To sum up, the compose
and pipe
functions are useful tools in functional programming for combining multiple functions into a single function or chain of functions.
These functions can help you create more readable, cleaner, and maintainable code by defining small, reusable functions and composing them to perform more complex tasks.
In this article, we learned how to create typed compose
and pipe
functions in TypeScript using generics and the built-in Array.prototype.reduce()
method, and we also learned how to use these functions to perform function composition in TypeScript.
Whether you are new to functional programming or an experienced developer, understanding the concepts of compose
and pipe
functions can help you to write more efficient and effective code. Let me know about your experiences creating compose functions for your TypeScript projects or in general!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.