如何编写支持类型推断的TypeScript异步函数组合asyncPipe?
我最近重新探索TypeScript的时候,发现它在函数组合的类型标注能力上有个不小的局限——尤其是像asyncPipe这种混合了普通函数和Promise的异步组合工具,很难让TypeScript自动正确推断出输入输出类型。先给大家看看我想要做类型标注的原始JavaScript代码:
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.'
这里的asyncPipe是从左到右(非数学组合的反向顺序)来组合普通函数和Promise的。我希望写出一个TypeScript版本的asyncPipe,能自动识别输入输出类型:比如userHasEvenName应该明确知道它接收number类型的参数,返回Promise<boolean>;如果我注释掉getUserById和asyncIsEven,它应该能推断出接收User类型参数,返回number类型。
目前我已经把基础函数都加上了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);
我试过很多asyncPipe的实现,但类型推断效果都不好。了解到TypeScript编写compose函数需要大量重载,因为TS无法处理反向推断,而且compose是数学顺序(从右到左)。但asyncPipe是从左到右组合,我觉得应该可行,目前已经写出了能组合两个普通函数的pipe2:
function pipe2<A, B, C>(f: (arg: A) => B, g: (arg: B) => C): (arg: A) => C { return x => g(f(x)); }
现在的问题是:如何编写可异步组合任意数量函数或Promise、且能正确推断返回类型的asyncPipe?
解决方案:利用递归类型与Awaited实现类型安全的asyncPipe
要实现支持任意数量函数的类型推断,我们需要借助TypeScript的递归条件类型和Awaited工具类型(用来解包Promise的类型),同时通过函数重载来处理不同数量的参数情况。
以下是完整的实现:
// 定义递归类型,计算函数组合后的输入输出类型 type AsyncPipeFn<T> = T extends [(arg: infer A) => infer B, ...infer Rest] ? Rest extends [] ? (arg: A) => Promise<Awaited<B>> : (arg: A) => Promise<Awaited<ReturnType<AsyncPipeFn<[(arg: Awaited<B>) => ReturnType<Rest[0]>, ...Rest extends [any, ...infer R] ? R : []]>>>> : never; // 函数重载:覆盖0个、1个、多个函数的场景 function asyncPipe(): <T>(x: T) => Promise<T>; function asyncPipe<T, R>(fn: (arg: T) => R): (arg: T) => Promise<Awaited<R>>; function asyncPipe<T, A, R>(fn1: (arg: T) => A, fn2: (arg: Awaited<A>) => R): (arg: T) => Promise<Awaited<R>>; function asyncPipe<T, A, B, R>(fn1: (arg: T) => A, fn2: (arg: Awaited<A>) => B, fn3: (arg: Awaited<B>) => R): (arg: T) => Promise<Awaited<R>>; // 支持更多函数的泛型兜底 function asyncPipe<T extends Array<(...args: any[]) => any>>( ...fns: T ): AsyncPipeFn<T> { return (x: any) => fns.reduce(async (acc, fn) => fn(await acc), Promise.resolve(x)) as any; }
验证类型推断效果
现在用我们的asyncPipe来测试之前的场景:
// 自动推断出 userHasEvenName: (id: number) => Promise<boolean> const userHasEvenName = asyncPipe(getUserById, getName, countLetters, asyncIsEven); userHasEvenName(1).then(res => { // res的类型是boolean console.log(res); // false }); userHasEvenName(2).catch(err => { // err的类型是string console.log(err); // 'User not found.' }); // 测试去掉首尾函数的情况 const countUserNameLength = asyncPipe(getName, countLetters); // 自动推断出 countUserNameLength: (arg: { displayName: string; }) => Promise<number> countUserNameLength({ displayName: "Jan" }).then(len => { // len的类型是number console.log(len); // 3 });
实现说明
- 递归类型
AsyncPipeFn:遍历函数数组,依次将前一个函数的输出(解包Promise后)作为后一个函数的输入,递归计算最终的函数类型。 - 函数重载:通过重载处理不同数量的函数参数,让TypeScript在调用时能更精准地推断类型;对于超过重载数量的函数,递归类型依然能正确计算。
Awaited类型:自动解包Promise的类型,确保异步函数的输出能正确传递给下一个函数的输入。
这样实现的asyncPipe既支持普通函数,也支持返回Promise的异步函数,并且能全程自动推断输入输出类型,完全满足需求。
内容的提问来源于stack exchange,提问作者J. Hesters

