DEV Community

Matt Kane
Matt Kane

Posted on • Updated on

Creating a typed "compose" function in TypeScript

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))));
Enter fullscreen mode Exit fullscreen mode

into this:

const composed = compose(fn1, fn2, fn3, fn4);

const output = composed(input);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Imagine this in an imaginary JavaScript environment:

const largest = tail(sort(du("*")));
Enter fullscreen mode Exit fullscreen mode

You could implement it with pipe as:

const findLargest = pipe(du, sort, tail);
const largest = findLargest("*");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)))");
  });
});
Enter fullscreen mode Exit fullscreen mode

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.

inferred function signature

Here you can see that the type of pipedFunction has been inferred from the types of the functions passed to pipe.

bad function type

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)))");
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
};
Enter fullscreen mode Exit fullscreen mode

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:

inferred type

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:

fixed params last in variable argument functions #1360

Extracting this suggestion from this issue: https://github.com/Microsoft/TypeScript/issues/1336

Currently the variable arguments list supports variable arguments only as the last argument to the function:

function foo(arg1: number, ...arg2: string[]) {
}
Enter fullscreen mode Exit fullscreen mode

This compiles to the following javascript:

function foo(arg1) {
    var arg2 = [];
    for (var _i = 1; _i < arguments.length; _i++) {
        arg2[_i - 1] = arguments[_i];
    }
}
Enter fullscreen mode Exit fullscreen mode

However, variable argument functions are limited to appearing only as the last argument and not the first argument. I propose support be added for having a variable argument appear first, followed by one or more fixed arguments:

function subscribe(...events: string[], callback: (message: string) => void) {
}

// the following would compile
subscribe(message => alert(message)); // gets all messages
subscribe('errorMessages', message => alert(message));
subscribe(
  'errorMessages',
  'customMessageTypeFoo123', 
  (message: string) => {
     alert(message);
  });

// the following would not compile
subscribe(); // supplied parameters do not match any signature of call target
subscribe('a1'); // argument of type 'string' does not match parameter of type '(message: string) => void'
subscribe('a1', 'a2'); // argument of type 'string' does not match parameter of type '(message: string) => void'
Enter fullscreen mode Exit fullscreen mode

subscribe compiles to the following JavaScript:

function subscribe() {
  var events= [];
  var callback = arguments[arguments.length - 1];
  for(var _i = 0; _i < arguments.length - 2; _i++) {
    events[_i] = arguments[_i];
  }
}
Enter fullscreen mode Exit fullscreen mode

notes: it should be impossible for typescript code to call this function with zero arguments when typechecking. If JS or untyped TS code calls it without arguments, callback will be undefined. However, the same is true of fixed arguments at the beginning of the function.

edit: used a more realistic/motivating example for the fixed-last/variable-arguments-first function.



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);

Enter fullscreen mode Exit fullscreen mode

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)))");
  });
});

Enter fullscreen mode Exit fullscreen mode

Top comments (13)

Collapse
 
skurfuerst profile image
Sebastian Kurfürst

Hey Matt,

Thanks for your article!

I am having a question I don't really understand: In my understanding, the compose function can compose functions which return different types than what they expect; i.e. when plugging the pipe together, the output type of the first function must match the input type of the second function.

So, as I understand it, if you compose n functions together, you would have n+1 types.

Is this also handled by your function above? If so, I do not understand yet how this works :)

All the best,
Sebastian

Collapse
 
ascorbic profile image
Matt Kane

Hi Sebastian,
This isn't as smart as that: it expects that they all return the same type. If be interested to see if there's a way of typing the other sort though.

Collapse
 
skurfuerst profile image
Sebastian Kurfürst

Hey Matt,

the only way I see it is via N+1 different generic types - though that is ofc. not as nice as your generic implementation.

See:

All the best, Sebastian

Thread Thread
 
stereobooster profile image
stereobooster

There is a way to simulate it, but wiht limitations github.com/reduxjs/redux/blob/686d...

Thread Thread
 
ascorbic profile image
Matt Kane

Yeah, I can't see any way that doesn't boil down to "use lots of repetitive overloads"

Collapse
 
waynevanson profile image
Wayne Van Son • Edited

I got composable to work the same as pipe: multiple initial arguments.

export function compose<R, F extends (a: R, ...b: any) => R>(
  fn1: F,
  ...fns: Array<(a: R) => R>
) {
  return fns.reduce(
    (prevFn, nextFn) => value => prevFn(nextFn(value)),
    fn1
  ) as F;
}

const a = (v: string, q: number, s: boolean) => v + q;
const b = (v: string) => v;

const c = compose(a, b, b, b, b, b, b);
Enter fullscreen mode Exit fullscreen mode

I can say that I've been wanting to find this post for at least a year!

Collapse
 
mackentoch profile image
John Doe

Thank you for this article.

But I feel like the more I practice and read about Typescript the more it seems better for OOP programming rather than functional programing (React uses so much more functional concepts than Angular for instance).

It is such a pain (maybe just because I'm too ignorant with TS?) in a React application (HOC, composition...) that I feel more productive and things stay far more simple with FlowJS (only where I need not all codebase).

Collapse
 
ascorbic profile image
Matt Kane

I don't agree here. The reason I wrote about these is precisely because they're edge cases. There's no reason that FP should be any harder than OOP in TypeScript. If you're happy with Flow, in 99% of cases the TypeScript syntax is basically identical. The main difference you'll see is just better tooling and more typings available.

Collapse
 
mackentoch profile image
John Doe

Agreeing is not important, but sharing thoughts is what make us evolve.

Thank you for taking time replying 🙏

Collapse
 
krumpet profile image
Ran Lottem

Great stuff! I played around with a ComposableOn type, to implement compose for different types:

type FunctionType = (...x: any[]) => any;
type ComposableOn<F extends FunctionType> = F extends (...x: any[]) => infer U ? (y: U) => any : never;

This enforces that a function f2 can be composed on f1 to give f2(f1(...args).

Still couldn't do anything too cool about a variadic implementation. Something like CPP's <typename... T> or any of the other suggestions in comments here would be really cool.

Collapse
 
kotarski profile image
Edward Kotarski

I wrote an NPM package for this (npmjs.com/package/ts-functionaltypes)
Which checks the function types for the entire pipe

Collapse
 
babak profile image
Babak

Hey Matt--you might find my article on creating a recursive pipe / compose of interest

dev.to/babak/introducing-the-recur...

Collapse
 
abraham profile image
Abraham Williams

Nice write up and I love the inclusion of tests!