TypeScript: Call overloaded asyncPipe with 0 or more arguments

Viewed 180

I have an asyncPipe function like this:

export function asyncPipe<A, B>(
  ab: (a: A) => MaybePromise<B>
): (a: MaybePromise<A>) => Promise<B>;
export function asyncPipe<A, B, C>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>
): (a: MaybePromise<A>) => Promise<C>;
export function asyncPipe<A, B, C, D>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>
): (a: MaybePromise<A>) => Promise<D>;
export function asyncPipe<A, B, C, D, E>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>
): (a: MaybePromise<A>) => Promise<E>;

export function asyncPipe(...fns: Function[]) {
  return (x: any) => fns.reduce(async (y, fn) => fn(await y), x);
}

I want to call it like this:

const createRoute = (...middleware: Middleware[]) => async (
  request: Response,
  response: Request
) => {
  try {
    await asyncPipe(...middleware)({
      request,
      response
    });
  } catch (e) {
    console.error(e);
    response.status(500);
    response.json({
      error: "Internal Server Error"
    });
  }
};

where Middleware is a type for functions take in an object with at least the keys request and response, but might add more data the object.

{
  request,
  response,
  user, //  added by some middleware
  db, //  added by another middleware
  /* ... more keys added by middleware */
}

TypeScript complains and says Expected 1-6 arguments, but got 0 or more. in the line in which I call asyncPipe. How Can I tell it, that middleWare will always be at least one argument, or overload it, so it accepts 0 arguments, too?

Update:

I tried Rob's answer like this:

export function asyncPipe<A>(...fns: Function[]): MaybePromise<A>;

But that prevents all of my asyncPipe specific tests to fail. The tests btw, look like this:

import { describe } from "riteway";

import { asyncPipe } from "./asyncPipe";

const asyncInc = (n: number) => Promise.resolve(n + 1);
const inc = (n: number) => n + 1;

describe("asyncPipe()", async assert => {
  assert({
    given: "a promise",
    should: "pipe it",
    actual: await asyncPipe(asyncInc)(1),
    expected: 2
  });

  assert({
    given: "two promises",
    should: "pipe them",
    actual: await asyncPipe(asyncInc, asyncInc)(1),
    expected: 3
  });

  assert({
    given: "three promises",
    should: "pipe them",
    actual: await asyncPipe(asyncInc, asyncInc, asyncInc)(1),
    expected: 4
  });

  assert({
    given: "promises mixed with synchronous function",
    should: "pipe them",
    actual: await asyncPipe(asyncInc, inc, asyncInc)(1),
    expected: 4
  });

  {
    const throwInc = (n: number) => Promise.reject(n + 1);

    assert({
      given: "promises where one throws",
      should: "pipe them",
      actual: await asyncPipe(asyncInc, throwInc, asyncInc)(1).catch(x => x),
      expected: 3
    });
  }
});
3 Answers

Add a "zero or more" definition. For example, you can expose your implementation's definition:

// Previous definitions
export function asyncPipe(...fns: Function[]);

export function asyncPipe(...fns: Function[]) {
  return (x: any) => fns.reduce(async (y, fn) => fn(await y), x);
}

Explicitly typing asyncPipe like you tried offers best DX, as parameters of current step are inferred from the return type of the previous step. The downside of it is that only limited number of functions could be typed.

It is possible to type asyncPipe for an arbitrary number of functions - for example - with recursive types. But the downside of this approach is that types do not flow smoothly - parameters of the next function must be specified explicitly / manually to compile. As such it is not a problem if function to compose are already typed. If they are not, as a workaround to provide good ergonomics one could call asyncPipe in a nested manner and/or create some params builder taking one function at a time.

type MaybePromise<T> = T | Promise<T>

/*
  Handle empty arguments case - 
  return function which cannot be called:
*/
type EmptyPipe = (a: never) => Promise<never>
// export function asyncPipe(): EmptyPipe // -- not needed as cover by general case
export function asyncPipe<A, B>(
  ab: (a: A) => MaybePromise<B>
): (a: A) => Promise<B>; // asyncPipe should always return a function from a parameter of first function and Promise of return type of last function
export function asyncPipe<A, B, C>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>
): (a: A) => Promise<C>;
export function asyncPipe<A, B, C, D>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>
): (a: A) => Promise<D>;
export function asyncPipe<A, B, C, D, E>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
): (a: A) => Promise<E>;
// if needed please add overload for more functions like ef, fg etc.


// General case - arbitrary number of arguments
export function asyncPipe<FS extends any[]>(...fns: AsyncParams<FS>): AsyncPipeReturnType<FS>

// implemenation
export function asyncPipe (...fns: AsyncParams<any[]>) {
  if (fns.length === 0) return () => Promise.resolve(null)
  return (x: Parameters<typeof fns[0]>[0]) => fns.reduce(async (y, fn) => fn(await y), x)
}

// some auxiliary types
type Fun = (a: any) => MaybePromise<unknown>
type FunToReturnType<F> = F extends Fun ? ReturnType<F> extends Promise<infer U> ? U : ReturnType<F> : never

// this types assumes arguments are valid
type AsyncPipeReturnType<FS extends Fun[], P = Parameters<FS[0]>[0]> = 
  // P is parameter of first function; 
  FS extends [...infer _, infer Last] ? (a: P) => Promise<FunToReturnType<Last>> :
  EmptyPipe // no Last <=> length of FS is 0 

// recursively checks if list of function is compatible which each other 
type AsyncParams<FS extends Fun[], P = Parameters<FS[0]>[0]> =
  FS extends [infer H, ...infer Rest] ?
    H extends (p: P) => unknown ?
      Rest extends Fun[] ?
        // if current function and rest are okay, then pass current function, recurse with Rest and force next parameter based on current return type
        [H, ...AsyncParams<Rest, FunToReturnType<H>>] 
          // some hacks for pinning down exact issue and prevent compilation 
          : [{ error: "__A_PARAMETER_NOT_A_FUNCTION__" }, ...Rest]
            : [{ error: "__INCORRECT_FUNCTION__", provided: H, expected_parameter: P }, ...Rest]
              : FS // handles FS of length 0 and length 1;


const single = asyncPipe((a: string) => Promise.resolve(a.length)) // (a: string) => Promise<number>
const four = asyncPipe(
  (a: string) => Promise.resolve(a.length),
  (n) => [n, n],
  (c) => Promise.resolve(c[0]),
  aNumber => ({ aNumber })
) // (a: string) => Promise<{ aNumber: number }>

// general cases - parameters DO NOT inferred based on a return type of previous function
const zero = asyncPipe() // (a: never) => Promise<never>
const fiveAndMore = asyncPipe( // (a: string) => Promise<number>
  (a: string) => Promise.resolve(a.length),
  (n: number) => [n, n], 
  (c: number[]) => Promise.resolve(c[0]),
  (c: number) => `${c * c}`,
  // (c: boolean) => c -- ERROR
  (c: string) => c.length,
)

// workaround for letting the types flow
const nineWorkaround = asyncPipe( // (a: string) => Promise<number>
  four, // four async function already composed
  (x) => `${x.aNumber}`,
  asyncPipe(
      s => s.length,
      n => ({ aNumber: n })
    ),
  a => a.aNumber
) 

PLAYGROUND

I've done some extra research and taken some of the answers here to make my own answer. You have two options now:

Option 1

One way of doing it is to just write the code of the asyncPipe function into the createRoute function:

const createRoute = (...middleware: Middleware[]) => async (
  request: Response,
  response: Request
) => {
  try {
    middleware.reduce(async (y, fn) => fn(await y), { request, response });
  } catch (e) {
    console.error(e);
    response.status(500);
    response.json({
      error: "Internal Server Error"
    });
  }
};

This will definitely work, but I'm not sure if you have that type detection. Try the second option:

Option 2

The second way of doing it is a bit more complex. It will involve complex typings, but I think this is somehow what you want. (This code is taken from Arturs answer and modified)

type MaybePromise<T> = T | Promise<T>

export function asyncPipe<A, B>(
  ab: (a: A) => MaybePromise<B>
): (a: A) => Promise<B>;
export function asyncPipe<A, B, C>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>
): (a: A) => Promise<C>;
export function asyncPipe<A, B, C, D>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>
): (a: A) => Promise<D>;
export function asyncPipe<A, B, C, D, E>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
): (a: A) => Promise<E>;

export function asyncPipe<FS extends any[]>(...fns: AsyncParams<FS>): AsyncPipeReturnType<FS>
export function asyncPipe (...fns: AsyncParams<any[]>) {
  if (fns.length === 0) return () => Promise.resolve(null)
  return (x: Parameters<typeof fns[0]>[0]) => fns.reduce(async (y, fn) => fn(await y), x)
}

type Callback = (a: any) => MaybePromise<unknown>;
type FunToReturnType<F> = F extends Callback ? ReturnType<F> extends Promise<infer U> ? U : ReturnType<F> : never;
type EmptyPipe = (a: never) => Promise<never>

type AsyncPipeReturnType<FS extends Callback[], P = Parameters<FS[0]>[0]> = 
  FS extends [...infer _, infer Last] ? (a: P) => Promise<FunToReturnType<Last>> : EmptyPipe;

type AsyncParams<FS extends Callback[], P = Parameters<FS[0]>[0]> =
  FS extends [infer H, ...infer Rest] ?
    H extends (p: P) => unknown ?
      Rest extends Callback[] ?
        [H, ...AsyncParams<Rest, FunToReturnType<H>>] 
          : [{ error: "__A_PARAMETER_NOT_A_FUNCTION__" }, ...Rest]
            : [{ error: "__INCORRECT_FUNCTION__", provided: H, expected_parameter: P }, ...Rest]
              : FS;

You can try it out here

It is not possible to automatically infer the FS type.

If you want the next middleware of knowing about the previous one, you have to modify the type a bit and add it to the createRoute function.

Related