TypeScript: How to write an asyncPipe function for asynchronous function composition?
C

3

5

I'm recently exploring TypeScript again. One of it's key limitations seems to be the incapability of typing function composition. Let me first show you the JavaScript code. I'm trying to type this:

const getUserById = id => new Promise((resolve, reject) => id === 1
  ? resolve({ id, displayName: 'Jan' })
  : reject('User not found.')
);
const getName = ({ displayName }) => displayName;
const countLetters = str => str.length;
const asyncIsEven = n => Promise.resolve(n % 2 === 0);

const asyncPipe = (...fns) => x => fns.reduce(async (y, f) => f(await y), x);

const userHasEvenName = asyncPipe(
    getUserById,
    getName,
    countLetters,
    asyncIsEven
);

userHasEvenName(1).then(console.log);
// ↳ false
userHasEvenName(2).catch(console.log);
// ↳ 'User not found.'

Here asyncPipe composes regular functions as well as promises in anti-mathematical order (from left to right). I would love to write an asyncPipe in TypeScript, that knows about the input and output types. So userHasEvenName should know, that it takes in a number and returns a Promise<boolean>. Or, if you comment out getUserById and asyncIsEven it should know that it takes in a User and returns a number.

Here are the helper functions in TypeScript:

interface User {
    id: number;
    displayName: string;
}

const getUserById = (id: number) => new Promise<User>((resolve, reject) => id === 1
    ? resolve({ id, displayName: 'Jan' })
    : reject('User not found.')
);
const getName = ({ displayName }: { displayName: string }) => displayName;
const countLetters = (str: string) => str.length;
const asyncIsEven = (n: number) => Promise.resolve(n % 2 === 0);

I would love to show you all my approaches for asyncPipe but most were way off. I found out that in order to write a compose function in TypeScript, you have to heavily overload it because TypeScript can't handle backwards inference and compose runs in mathematical order. Since asyncPipe composes from left to right, it feels like it's possible to write it. I was able to explicitly write a pipe2 that can compose two regular functions:

function pipe2<A, B, C>(f: (arg: A) => B, g: (arg: B) => C): (arg: A) => C {
    return x => g(f(x));
}

How would you write asyncPipe that asynchronously composes an arbitrary amount of function or promises and correctly infers the return type?

Calla answered 9/2, 2020 at 11:13 Comment(0)
M
8

Variant 1: Simple asyncPipe (playground):

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

function asyncPipe<A, B>(ab: (a: A) => MaybePromise<B>): (a: MaybePromise<A>) => Promise<B>
function asyncPipe<A, B, C>(ab: (a: A) => MaybePromise<B>, bc: (b: B) => MaybePromise<C>): (a: MaybePromise<A>) => Promise<C>
// extend to a reasonable amount of arguments

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

Example:

const userHasEvenName = asyncPipe(getUserById, getName, countLetters, asyncIsEven);
// returns (a: MaybePromise<number>) => Promise<boolean>

Caveat: That will always return a promise, even if all function arguments are sync.


Variant 2: Hybrid asyncPipe (playground)

Let's try to make the result a Promise, if any of the functions are async, otherwise return the sync result. Types get bloated really quickly here, so I just used a version with one overload (two function arguments).

function asyncPipe<A, B, C>(ab: (a: A) => B, bc: (b: Sync<B>) => C): < D extends A | Promise<A>>(a: D) => RelayPromise<B, C, D, C>
// extend to a reasonable amount of arguments

function asyncPipe(...fns: Function[]) {
    return (x: any) => fns.reduce((y, fn) => {
        return y instanceof Promise ? y.then(yr => fn(yr)) : fn(y)
    }, x)
}

I defined two helpers: Sync will always get you the resolved Promise type, RelayPromise will transform the last type parameter to a promise, if any of the other parameters is a promise (see playground for more infos).

Example:

const t2 = asyncPipe(getName, countLetters)(Promise.resolve({ displayName: "kldjaf" }))
// t2: Promise<number>

const t3 = asyncPipe(getName, countLetters)({ displayName: "kldjaf" })
// t3: number

Caveat: If you want both sync + async in one type, it will get very complex and you should test it extensively (there may be still some πŸ› in my sample, I only used the simple version so far).

Also there is probably a compatibility reason, why fp-ts uses a special version of pipe, that makes better ussage of TypeScript's left to right type parameter inference (that could be a consideration for you as well).


Notes

Lastly, you should decide, if it's worth to have a special asyncPipe version just for Promises - more types and implementations means more potential bugs.

As an alternative, use the simple pipe with functors or monads in the functional programming style. E.g. instead of using a promise, you can switch to a Task or TaskEither types (see fp-ts as an example).

Miniature answered 9/2, 2020 at 13:35 Comment(10)
In both examples you use (x: any) and Function[], which both constitute blanks in the resulting types. I don't know Typescript much, but this seems to be hacking on the type level. – Nancynandor
From the caller perspective, only the function overload signatures count - and these will be strong types. If the function body implementation is not complex like in this case, (x: any) and Function[] don't really matter much. Of course, you could try to type the body as well, but more often than not it over complicates things without much return. – Miniature
Sounds kind of reasonable. I tried to comprehend the calling site aspect by applying asyncPipe(sqrP, sqrP, toUCP) where sqrP = (x: number): Promise<number> => Promise.resolve(x * x) and toUCP = (x: string): Promise<string> => Promise.resolve(x.toUpperCase()). A type error was raised, however, due to wrong number of arguments for asyncPipe rather than invalid function types. The overloaded n-ary case doesn't seem to work. – Nancynandor
yeah, as said I only implemented the overload with two function args for clarity here. If you want a pipe with three args (and so on), you can do it like this. – Miniature
Ah, the type is arity aware. Thanks, +1. Anyway, I think the underlying problem is that with generics you cannot type the function array other than Array<(_: A) => A>. – Nancynandor
When you define a compositon function that takes an array of functions in Haskell, you get the following type: Foldable t => t (a -> a) -> a -> a where t may be the array or any other traversable composite type. With parametric polymorphism (generics) this is the only possible type for such function. That's what I meant. – Nancynandor
@Miniature Wow, thank you so much. The links in your answer were also incredibly helpful. Could you recommend more resources for functional programming in TS? – Calla
@J.Hesters I would recommend learning general concepts and basics first (probably with JS) - JS currently offers much more FP resources than TS. Some concepts can be easily carried over to TS directly (immutability, etc.). Then you could integrate fp libraries with typing support to make use of pipe, curry etc. – Miniature
When it comes to algebraic structures, fp-ts seems to be the de-facto-standard in TS (I would do that as last step) – Miniature
@J.Hesters some 3-ish years ago when I first looked at Typescript the inability to type functional concepts like partial application was definitely a limitation, but Typescript has pretty solid FP support (considering the limitations forced by JS interop) these days. – Martica
O
0

An async version with n-ary support:

class Nary extends Array {}
const pipe = (...fns: Function[]) => (...x: any[]) => fns.reduce((y, fn) => y instanceof Promise ? y.then(fn) : y instanceof Nary ? fn(...y) : fn(y), x.length > 1 ? Nary.from(x) : x[0])
Ogawa answered 6/6, 2022 at 12:41 Comment(0)
T
0

Firstly I had to improve the logic little bit since it did not concatenated results so I could use all the data in all functions without worrying about sequence.

type MaybePromise<T> = Promise<T> | T;
export function asyncPipe<TParameter extends Record<string, unknown>>(
  ...fns: ((p: Partial<TParameter>) => MaybePromise<Partial<TParameter>>)[]
) {
  return (x: MaybePromise<Partial<TParameter>>) => {
    let result = { ...x } as Partial<TParameter>;
    return fns.reduce(async (y, f) => {
      const nextParam = await y;
      result = { ...result, ...nextParam };
      return f(result);
    }, x);
  };
}

Use it then like this:

 it('should invoke all funcs', async () => {
    type Data = Partial<{ test: string; f1Result: number; f2Result: string }>;
    type Handler = (input: Data) => Promise<Data> | Data;
    const f1: Handler = (input) =>
      new Promise((resolve) => {
        resolve({ f1Result: 12 });
      });

    const f2: Handler = (input) =>
      new Promise((resolve) => {
        resolve({ f2Result: `${input.test} ${input.f1Result}` });
      });

    const sut = asyncPipe(f1, f2);

    const result = await sut({ test: 'asd' });

    expect(result.f2Result).toBe('asd 12');
  });
Tinware answered 25/3 at 12:36 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.