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?
To type an async function in TypeScript, set its return type to Promise<type> . Functions marked async are guaranteed to return a Promise even if you don't explicitly return a value, so the Promise generic should be used when specifying the function's return type.
Inside an async function, you can use the await keyword before a call to a function that returns a promise. This makes the code wait at that point until the promise is settled, at which point the fulfilled value of the promise is treated as a return value, or the rejected value is thrown.
async / await support in ES6 targets (Node v4+) Asynchronous functions are prefixed with the async keyword; await suspends the execution until an asynchronous function return promise is fulfilled and unwraps the value from the Promise returned.
The async-await syntax helps with readability, allowing you to write the code as if it were using synchronous call patterns. To enable this method of communication, you'll need to modify your function prototype. In the declaration of the function prototype, before the word function, add the keyword async.
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.
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).
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).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With