Creating a typed "compose" function in TypeScript
January 02, 2019
I recently wrote a couple of small utility functions that turned into a deep exploration of TypeScript generics, as well as the new typed tuples in TypeScript 3. This post assumes you know at least a bit about generics in TypeScript. If you don’t, I highly recommend reading up on them, as they’re one of the most powerful features in the language. It assumes no prior knowledge of functional programming.
One of the core concepts in functional programming is composition, which is where several functions are combined into one function that performs all of the tasks. The output of one function is passed to the input of another. Lots of libraries include a compose
function to help with this, including Lodash and Ramda, and it also a pattern used a lot in React. These helpers allow you to turn this:
const output = fn1(fn2(fn3(fn4(input))));
into this:
const composed = compose(fn1, fn2, fn3, fn4);
const output = composed(input);
The new composed
function can then be reused as needed. The partner of compose
is pipe
, which is just compose with the arguments reversed. In this case the arguments are in the order they are executed, rather than the order they appear when nested. In the compose example above, it is the inner fn4
function that is called first, the output of which is then passed out to fn3
, then fn2
and fn1
. In a lot of situations it is more intuitive to think of the functions like a Unix pipe, when the value is passed in to the left, then piped from each function to the next. As a contrived example, the shell command to find the largest files in a directory would be:
du -s * | sort -n | tail
Imagine this in an imaginary JavaScript environment:
const largest = tail(sort(du("*")));
You could implement it with pipe as:
const findLargest = pipe(du, sort, tail);
const largest = findLargest("*");
In a more realistic example, imagine loading a JSON file, performing several operations on it, then saving it.
Best of all, a real world example: I implemented this because I was working on an Alexa quiz skill, where I was using a functional approach to handling requests. I passed an object that contained the request and response data through a series of handlers that checked if the user had answered the question, then asked the next question, then completed the game if appropriate.
const handler = pipe(handleAnswer, askQuestion, completeGame);
const params = handler(initialParams);
For this I had defined the interface for params, and wanted to be able to type the arguments. I wanted the signature of handler
to match the signature of the functions passed-in. I also wanted TypeScript to ensure that all of the functions passed-in were all of the same type. I also wanted it to be able to infer this, as it’s annoying to have to specify types unnecessarily. I wasn’t able to find any TypeScript library that supported this for arbitrary numbers of arguments, so let’s go ahead and write one.
First, let’s write the actual functions, and then work out how to type them. This is quite simple using the built-in Array.reduce
. We’ll start with pipe
, as that doesn’t require any changes to the argument order. Let’s remind ourselves of the signature of the simplest form of reduce
:
function reduce(callbackfn: (previousValue: T, currentValue: T) => T): T;
reduce
is passed a callback function that is called for each element in the array. The first argument passed to the the callback is the return value from the previous callback. The second argument is the next value from the array. For the short version of reduce, the first call to callback actually passes the first element in the array as previousValue
, and the second as currentValue
. There is a longer version that lets you pass an initial value, which is what you need to do if the return value will be different from the type of the array elements. We’ll start with the simpler version:
export const pipe = (...fns) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)));
This gradually builds up the composed function step-by-step, adding the next function in the array as we iterate through it. The callback returns a new function that in turn calls prevFn
(which is the function composed from the previous functions in the array), and then wraps that in a call to nextFn
. On each call, it wraps the function in the next one, until finally we have one function that calls all of the elements in the array.
export const pipe = <R>(...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)));
This looks quite confusing (as generic types often do) but it’s not as bad as it looks. The <R>
is a placeholder for the return type of the function. While this is a generic function, the neat thing is that TypeScript can infer this type from the type of the arguments that are passed to it: if you pass it a string, it knows that it will return a string. The signature says that pipe
accepts any number of arguments, which are all functions that accept one argument and return a value of the same type as that argument. This isn’t quite right though: pipe
needs at least one argument. We need to change the signature to show the first argument is required. We do this by adding an initial parameter with the same type, and then passing that as the second argument to reduce
, which means it’s used as the starting value.
export const pipe = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
Now we have defined pipe
, defining compose
is as simple as switching the order of nextFn
and prevFn
:
export const compose = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => prevFn(nextFn(value)), fn1);
Before we go any further, we need to test that it’s all working as expected. I like to use Jest for testing, so let’s define some tests to see how it should be working:
import { compose, pipe } from "./utils";
describe("Functional utils", () => {
it("composes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const composedFunction = compose(fn1, fn2, fn3);
expect(composedFunction("inner")).toBe("fn1(fn2(fn3(inner)))");
});
it("pipes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner")).toBe("fn3(fn2(fn1(inner)))");
});
});
These functions just return strings showing that they were called. They’re using template literals, if you’re not aware of the backtick syntax. Take a look at the typings of the composed functions to see how they’re doing, and try changing the signatures of the functions to see that type-checking works.
Here you can see that the type of pipedFunction
has been inferred from the types of the functions passed to pipe
.
Here we can see that changing fn2
to expect a number causes a type error: the functions should all have the same signature.
We’d get a similar error if we passed a function that accepted more than one argument. However this doesn’t need to be a limitation of a compose
or pipe
function. Strictly speaking, the first function could accept anything, as long as it returns the same type, and all of the other functions take a single argument. We should be able to get the following working:
it("pipes functions with different initial type", () => {
const fn1 = (val: string, num: number) => `fn1(${val}-${num})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner", 2)).toBe("fn3(fn2(fn1(inner-2)))");
});
The composed or piped function should have the same signature as the first function. So how should we type this? We can change the type of fn1
to allow different arguments, but then we lose our type safety of those arguments:
export const pipe = <R>(
fn1: (...args: any[]) => R,
...fns: Array<(a: R) => R>
) => fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
We need another generic type to represent the args of fn1
. Before TypeScript 3, we’d be reduced to adding loads of overloads:
export function pipe<T1, R>(
fn1: (arg1: T1) => R,
...fns: Array<(a: R) => R>
): (arg1: T1) => R;
export function pipe<T1, T2, R>(
fn1: (arg1: T1, arg2: T2) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2) => R;
export function pipe<T1, T2, T3, R>(
fn1: (arg1: T1, arg2: T2, arg3: T3) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2, arg3: T3) => R;
export function pipe<T1, T2, T3, T4, R>(
fn1: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => R;
export function pipe<R>(
fn1: (...args: any[]) => R,
...fns: Array<(a: R) => R>
): (a: R) => R {
return fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
}
This is clearly ridiculous. Luckily TypeScript 3 introduces typed ...rest
parameters. This lets us do this, which works with any number of arguments:
export const pipe = <T extends any[], R>(
fn1: (...args: T) => R,
...fns: Array<(a: R) => R>
) => {
const piped = fns.reduce(
(prevFn, nextFn) => (value: R) => nextFn(prevFn(value)),
value => value
);
return (...args: T) => piped(fn1(...args));
};
We’ve added a new generic type T
, which represents the arguments of the first function. Before TypeScript 3 this code gave an error, but now by stating that it extends any[]
, the compiler accepts it as a typed tuple parameter list. We can’t pass fn1
directly to reduce, as we were doing before, as it is now a different type. Instead we pass the identity function value => value
as the second value - a function that just returns its argument, unchanged. We then wrap the reduced function in another function with the correct type and return that.
This gives us a piped function with the same type as its first argument:
It still type-checks the other arguments too: they must all be functions that accept one argument of the same type returned by the first function, and they must all return the same type.
So where does this leave compose
? Unfortunately we can’t type it in the same way. While pipe
takes its type from the first function passed to it, compose
uses the type of the last function. Typing that would require ...rest
arguments at the beginning of the argument list, which aren’t supported yet:
{% github https://github.com/Microsoft/TypeScript/issues/1360 %}
Until then you’ll need to stick with composing functions that accept just one argument.
The final library is here:
export const pipe = <T extends any[], R>(
fn1: (...args: T) => R,
...fns: Array<(a: R) => R>
) => {
const piped = fns.reduce(
(prevFn, nextFn) => (value: R) => nextFn(prevFn(value)),
value => value
);
return (...args: T) => piped(fn1(...args));
};
export const compose = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => prevFn(nextFn(value)), fn1);
The tests for it, which can show usage:
import { compose, pipe } from "./utils";
describe("Functional helpers", () => {
it("composes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const composedFunction = compose(fn1, fn2, fn3);
expect(composedFunction("inner")).toBe("fn1(fn2(fn3(inner)))");
});
it("pipes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner")).toBe("fn3(fn2(fn1(inner)))");
});
it("pipes functions with different initial type", () => {
const fn1 = (val: string, num: number) => `fn1(${val}-${num})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner", 2)).toBe("fn3(fn2(fn1(inner-2)))");
});
});