sir-haver
sir-haver

Reputation: 3592

How to define a simple pipe function with generics?

I wrote a simple pipe function that accepts either asynchronous functions, or just values that are passed on without being executed.

I really tried to define it using generics but didn't make it so reverted to using unknown instead. What I have:

export const pipe = (...args: Array<unknown>): Promise<unknown> | unknown =>
  args.reduce((prev, exec) => {
    if (typeof exec !== 'function') {
      return exec;
    }

    const getNextInPipe = async (): Promise<unknown> => {
      return exec(await prev);
    };

    const value = getNextInPipe();
    return value;
  });

I tried to write it like this:

export const pipe = <T,>(...args: Array<unknown>): unknown =>
  args.reduce((prev, exec) => {
    if (typeof exec !== 'function') {
      return exec;
    }

    const getNextInPipe = async (): Promise<T> => {
      return exec(await prev);
    };

    const value = getNextInPipe();
    return value;
  });

But I don't know how to replace the other unknown, and if it can be done? Because the type of output of each function in the pipe doesn't depend on the type of input.

I'm still new to generics, thanks in advance

Upvotes: 1

Views: 191

Answers (1)

jcalz
jcalz

Reputation: 328342

Your function might be simple (that's debatable anyway) but the generic typings are anything but. You are trying to represent a "chain" of types of arbitrary length. Essentially you start with an initial value of type I, and then maybe a function of a type like (input: Awaited<I>) => Promise<Awaited<TFirst>> for some output type TFirst, and then maybe a function of a type like (input: Awaited<TFirst>) => Promise<Awaited<TSecond>>, etc. etc., and finally ending on a function of a type like (input: Awaited<TPenultimate>) => Promise<Awaited<TLast>>, and then the output of pipe() is a value of type Promise<Awaited<TLast>>, unless there were no functions and just an input I, in which case the output is I.

The parts with the Awaited type are dealing with the fact that if you await a non-promise value you get the value, so Awaited<string> is string, and Awaited<Promise<string>> is string... and you can't really nest promises, so Awaited<Promise<Promise<string>>> is also string.

So one approach to pipe() would look like this:

const pipe: <I, T extends any[]>(
  init: I,
  ...fns: { [N in keyof T]: (input: Awaited<Idx<[I, ...T], N>>) => T[N] }
) => T extends [...infer _, infer R] ? Promise<Awaited<R>> : I =
  (...args: any[]): any => args.reduce((prev, exec) => {
    if (typeof exec !== 'function') {
      return exec;
    }

    const getNextInPipe = async () => {
      return exec(await prev);
    };

    const value = getNextInPipe();
    return value;
  });

type Idx<T, K> = K extends keyof T ? T[K] : never;

The I type parameter corresponds to the type of the init function parameter. The T type parameter corresponds to the tuple of the output types of each of the functions in the fns rest parameter. So if there are two functions and the first function returns a Promise<boolean> and the second function returns a string, then T will be [Promise<boolean>, string].

The type of the fns argument is where the complexity lives. For the element of fns at numericlike index N (think 0 for the first one, 1 for the second one), we know that the output type is the Nth element of T, or the indexed access type T[N]. That's straightforward enough. But the input type comes from the previous element of T. Or maybe I. We represent that by first making [I, ...T], which uses a variadic tuple type to represent prepending I to T. Then we just need the Nth element of that. Conceptually that's the indexed access [I, ...T][N]. But the compiler isn't smart enough to realize that every numeric index N of the T tuple type will also be an index on the [I, ...T] tuple type. So I need to use the Idx helper type to convince the compiler to perform that indexing.

As for the output type, we need to tease apart T to find its last element R (using conditional type inference). If that exists, then we are returning a value of type Promise<Awaited<R>>. If not, it's because T is empty so we're just returning I.

Whew.


Okay let's test it. First of all the supported uses:

const z = pipe(3, (n: number) => n.toFixed(2), (s: string) => s.length === 4)
// const pipe: <3, [string, boolean]>(
//   init: 3, 
//   fns_0: (input: 3) => string, 
//   fns_1: (input: string) => boolean
// ) => Promise<boolean>
// const z: Promise<boolean>
z.then(v => console.log("z is", v)) // z is true

const y = pipe(4);
// const pipe: <4, []>(init: 4) => 4
// const y: 4
console.log("y is", y) // y is 4

const x = pipe(50, (n: number) => new Promise<string>(
  r => setTimeout(() => { r(n.toFixed(3)) }, 1000)), 
  (s: string) => s.length === 4);
// const pipe: <50, [Promise<string>, boolean]>(
//   init: 50, 
//   fns_0: (input: 50) => Promise<string>, 
//   fns_1: (input: string) => boolean
// ) => Promise<boolean>
// const x: Promise<boolean>
x.then(v => console.log("x is", v)) // x is false

That all looks good. z and x are promises of the expected type, while y is just a numeric value. Now for the unsupported cases:

pipe(); // error!  
// Expected at least 1 arguments, but got 0.

pipe(10, 20, 30); // error! 
// Argument of type 'number' is not assignable to parameter of type '(input: 10) => unknown'.

pipe(10, (x: string) => x.toUpperCase()) // error!     
// Type 'number' is not assignable to type 'string'.

pipe(10, (x: number) => x.toFixed(2), (x: boolean) => x ? "y" : "n") // error!
// Type 'string' is not assignable to type 'boolean'

Those all fail for violating constraints on the function. It needs at least one argument, and only the first argument can be a non-function. Each function needs to accept the awaited response of the previous function (or the initial value), and if it does not, then you get an error.


So that's about as good a job as I can do. It's not perfect; I'm sure you could find edge cases if you look. The obvious one is if you don't annotate the callback parameters then inference might fail. Something like pipe(10, x => x.toFixed(), y => y.toFixed()) should yield an error but doesn't, because the compiler fails to infer that x should be a number and it falls back to any, after which all the inputs and outputs are any. If you want it to be caught you need to write pipe(10, (x: number)=>x.toFixed(), (y: number)=>y.toFixed()). There may be tweaks that can improve this, but I'm not going to spend any more time trying to find them here.

The main point is that you can represent this sort of thing but it's not simple.

Playground link to code

Upvotes: 2

Related Questions