Typing pipe compose function that uses reduceRight with generics

Viewed 402

A while ago a friendly stack overflow user helped me to solve to get a compose function to work properly with TypeScript generics.

type OuterFunction<OA, OR> = (arg: OA) => OR;
type InnerFunction<IA extends unknown[], IR> = (...args: IA) => IR;

const compose = <T extends unknown[], V, W>(
  outer: OuterFunction<V, W>,
  inner: InnerFunction<T, V>
) => (...innerParams: T) => outer(inner(...innerParams));

This works great. So the resulting composed function has the arguments type of inner function and the return type of outer function, and correctly expects outer functions arguments to match inner functions return type.

And here is my pipe function utilizing that same compose with reduceRight, allowing to define functions from more than two other functions:

const pipe = (...functions: any[]) =>
  functions.reduceRight((prev, curr) => compose(prev, curr));

This runs and works JavaScript wise as expected. Types are, obviously, completely vanishing when done like this.

I was fiddling and doing more different ways than I can remember and it also doesn't really make sense to list them up here, because none of them worked at all. They all did not even compile, or only resulted in non types just as the basic version above.

Here this is in a TypeScript playground of this:
https://tsplay.dev/w62jEw

How does fp-ts do this?

I looked at how fp-ts does this with their pipe, and concluded that their solution will work very well, but is not as nice as using reduceRight (it is basically a huge overload cascade they do to achieve it).

Since piping > 18 functions together is actually not a scenario I have to expect as quite common, my fiddling for that solution is actually redundant.

But perhaps there is a nice solution out there, and in that case it might be interesting for even more than just myself to know about it ;)


TIA for any hints.

2 Answers

With Variadic Tuple Types, which are new feature in TypeScript 4.0, you can do the following:

type Func = (...args: any) => any
declare function pipe<T extends Func, U extends Func, R extends Func>
    (...functions: [T, ...U[], R]) : (...args: Parameters<T>) => ReturnType<R>;

Variadic Typle Types let you define ...functions array as [T, ...U[], R] and then use first and last function signatures (T and R) to extract input and return parameters.

Full example would look like this:

const onePlus = (a: number): number => a + 1;
const asString = (a: number): string => `${a}`;
const toInt = (a: string): number => Number.parseInt(a, 10);


type Func = (...args: any) => any
declare function pipe<T extends Func, U extends Func, R extends Func>
    (...functions: [T, ...U[], R]) : (...args: Parameters<T>) => ReturnType<R>;

const pipeWorks = pipe(
  onePlus,
  asString,
  toInt,
  onePlus,
  asString,
  toInt,
  onePlus
);

console.log(pipeWorks(1));

Or in the TS playground here.

If you leave only two pipe-functions:

const pipeWorks = pipe(
  onePlus,
  asString
);

pipeWorks function signature will be (a: number) => string.

However, note that this pipe function signature only picks input parameters of first function T and return parameter of the last function R. It does not type check any in-between function, if their signatures (input/output parameters) can be chained or not.

You would probably have to constrain Func type a bit more, to ensure chaining signatures are valid.

After several attempts, I have this solution.

type Func = (...args: any) => any

function pipe<T extends Func, U extends Func[], R extends Func>
    (...fns: [T, ...U, R]): (x: Parameters<T>[number]) => ReturnType<R> {
        return (x) => fns.reduce((y, f) => f(y), x as Parameters<T | U[number] | R>);
    }

const add1 = (x: number) => x + 1;
const add2 = (x: number) => x + 2;
const add3 = (x: number) => x + 3;
const add4 = (x: number) => x + 4;
const add10 = pipe(add1, add2, add3, add4); // all same type works
console.log(add10(2));

const mix = pipe(add1, String, Number); // mix type works
console.log(mix(2));

You can hover your mouse over pipe and see correct type inference, because by using Variadic Tuple Types, generic U is a tuple which contains middle function signatures.

Further Reading: I find this blog very well written about Variadic Tuple Types.

Updated:

If you want the first function to accept mutiple parameters, you have to revise the function body like below, though types are quite similar

const pipe2 = <T extends Func, U extends Func[], R extends Func>
    (...fns: [T, ...U, R]): (...args: Parameters<T>) => ReturnType<R> => 
        fns.reduce((f, g) => 
            (...args: [...Parameters<T>]) => g(f(...args)));

However this technique cannot handle zero or one parameter. We need to add function overloading to make it more general.

type Func = (...args: any) => any
type Noop = () => void;
const noop: Noop = () => {};

function pipe2(): Noop;
function pipe2<T extends Func>(fn: T): T;
function pipe2<T extends Func, U extends Func[], R extends Func>(...fns: [T, ...U, R]): (...args: Parameters<T>) => ReturnType<R>;
function pipe2(...fns: any) {
    if (fns.length === 0) return noop;
    if (fns.length === 1) return fns[0];
    return fns.reduce((f: any, g: any) =>
        (...args: any) => g(f(...args))
    );
}

const addxy = (x: number, y: number) => x + y;
const add1 = (x: number) => x + 1;
const add2 = (x: number) => x + 2;
const add3 = (x: number) => x + 3;
const noopAgain = pipe2();
const addxyAgain = pipe2(addxy);
const addxyAnd1 = pipe2(addxy, add1);
const addxyAnd1And2 = pipe2(addxy, add1, add2);
const addAll = pipe2(addxy, add1, add2, add3);
const mix = pipe2(addxy, String, Number);
console.log(addxyAgain(2, 3)); // -> 5
console.log(addxyAnd1(2, 3)); // -> 6
console.log(addxyAnd1And2(2, 3)); // -> 8
console.log(addAll(2, 3)); // -> 11
console.log(mix(2, 3)); // -> 5
Related