在 TypeScript 中创建类型化的“compose”函数
固定参数函数中的最后一个参数 #1360
我最近写了几个小的实用函数,这些函数最终让我对 TypeScript 泛型以及 TypeScript 3 中新增的类型化元组进行了深入的探索。本文假设你至少对 TypeScript 中的泛型有所了解。如果你不了解,我强烈建议你先了解一下,因为它们是 TypeScript 中最强大的功能之一。本文不要求你具备任何函数式编程的先验知识。
函数式编程的核心概念之一是组合,即将多个函数组合成一个执行所有任务的函数。一个函数的输出被传递给另一个函数的输入。许多库都包含一个compose函数来帮助实现这一点,包括 Lodash 和 Ramda,这也是 React 中经常使用的一种模式。以下帮助器允许你将它转换为:
const output = fn1(fn2(fn3(fn4(input))));
变成这样:
const composed = compose(fn1, fn2, fn3, fn4);
const output = composed(input);
然后可以根据需要重复使用新composed函数。的伙伴compose是pipe,它只是将参数反转后的组合。在这种情况下,参数按照它们执行的顺序排列,而不是嵌套时出现的顺序。在上面的组合示例中,fn4首先调用内部函数,然后将其输出传递给fn3,然后fn2是fn1。在很多情况下,将函数想象成 Unix 管道会更直观,即将值传递到左侧,然后通过管道从每个函数传递到下一个函数。举个例子,用于查找目录中最大文件的 shell 命令是:
du -s * | sort -n | tail
在一个假想的 JavaScript 环境中想象一下:
const largest = tail(sort(du("*")));
您可以使用管道来实现它:
const findLargest = pipe(du, sort, tail);
const largest = findLargest("*");
在一个更现实的例子中,想象一下加载一个 JSON 文件,对其执行几个操作,然后保存它。
最棒的是,这是一个真实的例子:我实现这个功能是因为我正在开发一个 Alexa 问答技能,其中我使用了一种函数式方法来处理请求。我将一个包含请求和响应数据的对象传递给一系列处理程序,这些处理程序会检查用户是否回答了问题,然后询问下一个问题,如果回答正确,则完成游戏。
const handler = pipe(handleAnswer, askQuestion, completeGame);
const params = handler(initialParams);
为此,我定义了 params 的接口,并希望能够指定参数的类型。我希望它的签名handler与传入函数的签名一致。我还希望 TypeScript 能够确保所有传入的函数都属于同一类型。我还希望它能够推断出这一点,因为不必要地指定类型很烦人。我找不到任何 TypeScript 库支持任意数量的参数,所以让我们自己写一个吧。
首先,让我们编写实际的函数,然后弄清楚如何定义它们的类型。使用内置的 可以很容易地做到这一点Array.reduce。我们将从 开始pipe,因为它不需要更改参数顺序。让我们回顾一下 的最简单形式的签名reduce:
function reduce(callbackfn: (previousValue: T, currentValue: T) => T): T;
reduce传递一个回调函数,该函数为数组中的每个元素调用。传递给回调的第一个参数是前一个回调的返回值。第二个参数是数组中的下一个值。对于 reduce 的简短版本,第一次调用回调实际上将数组中的第一个元素作为 传递previousValue,将第二个元素作为传递currentValue。有一个较长的版本允许您传递初始值,如果返回值与数组元素的类型不同,则需要这样做。我们将从更简单的版本开始:
export const pipe = (...fns) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)));
这会逐步构建组合函数,并在迭代过程中将下一个函数添加到数组中。回调函数返回一个新函数,该函数依次调用prevFn(该函数由数组中先前的函数组合而成),然后将其包装在对 的调用中nextFn。每次调用时,它都会将这个函数包装在下一个函数中,直到最终我们得到一个调用数组中所有元素的函数。
export const pipe = <R>(...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)));
这看起来很令人困惑(泛型类型经常这样)但它并不像看起来那么糟糕。<R>是函数返回类型的占位符。虽然这是一个泛型函数,但巧妙的是,TypeScript 可以根据传递给它的参数类型推断出这种类型:如果传递一个字符串,它就知道它将返回一个字符串。签名表明pipe接受任意数量的参数,这些参数都是接受一个参数并返回与该参数相同类型的值的函数。但这并不完全正确:pipe至少需要一个参数。我们需要更改签名以显示第一个参数是必需的。我们通过添加具有相同类型的初始参数,然后将其作为第二个参数传递给来实现这一点reduce,这意味着它被用作起始值。
export const pipe = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
现在我们已经定义了pipe,定义就像交换和的compose顺序一样简单:nextFnprevFn
export const compose = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => prevFn(nextFn(value)), fn1);
在继续之前,我们需要测试一下它是否按预期工作。我喜欢用Jest进行测试,所以让我们定义一些测试来看看它应该如何工作:
import { compose, pipe } from "./utils";
describe("Functional utils", () => {
it("composes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const composedFunction = compose(fn1, fn2, fn3);
expect(composedFunction("inner")).toBe("fn1(fn2(fn3(inner)))");
});
it("pipes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner")).toBe("fn3(fn2(fn1(inner)))");
});
});
这些函数只是返回一个字符串,表明它们被调用了。如果你不了解反引号语法,它们其实使用了模板字面量。你可以查看组合函数的类型,看看它们是如何工作的,并尝试修改函数的签名,看看类型检查是否有效。
这里您可以看到 的类型pipedFunction是根据传递给 的函数的类型推断出来的pipe。
这里我们可以看到,更改fn2为期望数字会导致类型错误:所有函数都应该具有相同的签名。
如果我们传递一个接受多个参数的函数,也会得到类似的错误。然而,这不一定是compose或pipe函数的限制。严格来说,第一个函数可以接受任何参数,只要它返回相同的类型,并且所有其他函数都接受一个参数。我们应该能够使以下内容正常工作:
it("pipes functions with different initial type", () => {
const fn1 = (val: string, num: number) => `fn1(${val}-${num})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner", 2)).toBe("fn3(fn2(fn1(inner-2)))");
});
组合函数或管道函数应该与第一个函数具有相同的签名。那么我们应该如何定义它的类型呢?我们可以更改它的类型fn1以允许不同的参数,但这样一来,我们就会失去这些参数的类型安全性:
export const pipe = <R>(
fn1: (...args: any[]) => R,
...fns: Array<(a: R) => R>
) => fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
我们需要另一个泛型类型来表示 的参数fn1。在 TypeScript 3 之前,我们只能通过添加大量的重载来实现:
export function pipe<T1, R>(
fn1: (arg1: T1) => R,
...fns: Array<(a: R) => R>
): (arg1: T1) => R;
export function pipe<T1, T2, R>(
fn1: (arg1: T1, arg2: T2) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2) => R;
export function pipe<T1, T2, T3, R>(
fn1: (arg1: T1, arg2: T2, arg3: T3) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2, arg3: T3) => R;
export function pipe<T1, T2, T3, T4, R>(
fn1: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => R,
...fns: Array<(a: R) => R>
): (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => R;
export function pipe<R>(
fn1: (...args: any[]) => R,
...fns: Array<(a: R) => R>
): (a: R) => R {
return fns.reduce((prevFn, nextFn) => value => nextFn(prevFn(value)), fn1);
}
这显然很荒谬。幸运的是,TypeScript 3 引入了类型...rest参数。这让我们可以做到这一点,并且适用于任意数量的参数:
export const pipe = <T extends any[], R>(
fn1: (...args: T) => R,
...fns: Array<(a: R) => R>
) => {
const piped = fns.reduce(
(prevFn, nextFn) => (value: R) => nextFn(prevFn(value)),
value => value
);
return (...args: T) => piped(fn1(...args));
};
我们添加了一个新的泛型类型T,它代表第一个函数的参数。在 TypeScript 3 之前,这段代码会报错,但现在通过声明它extends any[],编译器会将其作为类型化元组参数列表接受。我们不能fn1像以前那样直接传递给 reduce ,因为它现在是不同的类型。相反,我们将身份函数value => value作为第二个值传递——一个只返回其参数的函数,不做任何更改。然后,我们将简化后的函数包装到另一个具有正确类型的函数中并返回该函数。
这为我们提供了一个与其第一个参数相同类型的管道函数:
它仍然对其他参数进行类型检查:它们必须都是接受第一个函数返回的相同类型的一个参数的函数,并且它们都必须返回相同的类型。
那么这会把 留在哪里呢compose?不幸的是,我们不能用同样的方式输入它。While 从pipe传递给它的第一个函数中获取其类型,而compose使用最后一个函数的类型。输入 需要将...rest参数放在参数列表的开头,而目前还不支持:
固定参数函数中的最后一个参数 #1360
从该问题中提取此建议:https://github.com/Microsoft/TypeScript/issues/1336
目前,变量参数列表仅支持变量参数作为函数的最后一个参数:
函数 foo ( arg1 :数字, ... arg2 :字符串[ ] ) {
}
编译后为以下 JavaScript:
函数 foo ( arg1 ) {
var arg2 = [ ] ;
for ( var _i = 1 ; _i < 参数. length ; _i ++ ) {
arg2 [ _i - 1 ] = 参数[ _i ] ;
}
}
然而,可变参数函数只能作为最后一个参数出现,而不能作为第一个参数出现。我建议支持将可变参数放在最前面,然后跟随一个或多个固定参数:
函数 订阅( ...事件:字符串[ ] , 回调:(消息:字符串)= > void){ }
// 以下将编译
subscribe ( message => alert ( message ) ) ; // 获取所有消息
subscribe ( 'errorMessages' , message => alert ( message ) ) ;
subscribe (
'errorMessages' ,
'customMessageTypeFoo123' ,
( message : string ) => {
alert ( message ) ;
} ) ;
// 以下代码无法编译
subscribe ( ) ; // 提供的参数与调用目标的任何签名都不匹配
subscribe ( 'a1' ) ; // 'string' 类型的参数与 '(message: string) => void' 类型的参数不匹配
subscribe ( 'a1' , 'a2' ) ; // 'string' 类型的参数与 '(message: string) => void' 类型的参数不匹配
订阅编译为以下 JavaScript:
函数 订阅() {
var events = [ ] ;
var callback = argument [ arguments.length - 1 ] ; for ( var _i = 0 ; _i < argument.length - 2 ; _i ++ ) { events [ _i ] = argument [ _i ] ; } }
注意:在类型检查时,TypeScript 代码应该不可能以零参数调用此函数。如果 JS 或无类型 TS 代码在不带参数的情况下调用此函数,则回调函数将为 undefined。但是,如果函数开头有固定参数,则同样如此。
编辑:对固定最后/变量参数优先函数使用了更现实/更有激励性的例子。
在此之前,您需要坚持使用只接受一个参数的组合函数。
最终的库在这里:
export const pipe = <T extends any[], R>(
fn1: (...args: T) => R,
...fns: Array<(a: R) => R>
) => {
const piped = fns.reduce(
(prevFn, nextFn) => (value: R) => nextFn(prevFn(value)),
value => value
);
return (...args: T) => piped(fn1(...args));
};
export const compose = <R>(fn1: (a: R) => R, ...fns: Array<(a: R) => R>) =>
fns.reduce((prevFn, nextFn) => value => prevFn(nextFn(value)), fn1);
对它的测试可以显示其用法:
import { compose, pipe } from "./utils";
describe("Functional helpers", () => {
it("composes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const composedFunction = compose(fn1, fn2, fn3);
expect(composedFunction("inner")).toBe("fn1(fn2(fn3(inner)))");
});
it("pipes functions", () => {
const fn1 = (val: string) => `fn1(${val})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner")).toBe("fn3(fn2(fn1(inner)))");
});
it("pipes functions with different initial type", () => {
const fn1 = (val: string, num: number) => `fn1(${val}-${num})`;
const fn2 = (val: string) => `fn2(${val})`;
const fn3 = (val: string) => `fn3(${val})`;
const pipedFunction = pipe(fn1, fn2, fn3);
expect(pipedFunction("inner", 2)).toBe("fn3(fn2(fn1(inner-2)))");
});
});


