首页 > 解决方案 > 试图定义一个可变参数/相互关联类型的数组

问题描述

所以,我正在开发一个库来管理转换管道(从源,通过几个“垫片”,再到接收器)......并且遇到了一个特别是递归类型的问题。据说自 TS 3.7 以来这是可能的,但我认为我遇到了一个不是的边缘情况。

interface Sink<E> { seal():void; send(value:E):void; }
type Gasket<I,O> = (target:Sink<O>) => Sink<I>;

/**
 * The type of arrays of gaskets which go from a source type `I` to a sink type `O`.
 */
type Gaskets<I,O,Via=never> =
    // An array containing a single gasket from I to O.
    | [Gasket<I,O>]
    // A gasket from I to Via, followed by gaskets which go from Via to O.
    | [Gasket<I,Via>, ...Gaskets<Via, O>]
;

/**
 * `Join()` is used to cleanly wrap a single sink, or to assemble a sequence of
 * gaskets which goes from type I to type O and into a Sink<O> at the end.
 */
export function Join<I,O=never>(...arg: [Sink<I>]): Sink<I>;
export function Join<I,O>(...arg: [...Gaskets<I,O>, Sink<O>]): Sink<I>;

// @ts-ignore-error
export function Join<I,O>(...arg) {
  // ...
}

以上...不起作用。只是Gaskets<Via,O>错了,但至少不会出错……但是当我添加扩展运算符...前缀时,它会Gaskets is not generic ts(2315)在该标记处以及Type alias Gaskets circularly references itself ts(2456)在我声明type Gaskets自己的第一行上进行定位。

我有一个临时的解决方法,但它基本上意味着手动展开递归,因此支持有限数量的元素。在这里,它展开为 7 个垫圈......

type Gaskets<I,O,V1=never,V2=never,V3=never,V4=never,V5=never,V6=never> = 
    | [Gasket<I,O>]
    | [Gasket<I,V1>, Gasket<V1, O>]
    | [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2, O>]
    | [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, O>]
    | [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,O>]
    | [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,V5>, Gasket<V5,O>]
    | [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,V5>, Gasket<V5,V6>, Gasket<V6,O>]
;

根据我的规范,这是一个简化的示例,说明了如何使用它...

describe("Join", () => {
    it("builds multi-element transformation chains", () => {
        const receiver = jest.fn((e:string) => {});

        const fromString = (i:string) => parseFloat(i);
        const toString = (i:number) => String(i);
        const increment = (i:number) => i+1;

        const chain = P.Join(
            P.Map(fromString),
            P.Map(increment),
            P.Map(increment),
            P.Map(toString),
            P.CallbackSink(receiver)
        );

        chain.send("5");
        chain.send("-2");
        chain.seal();

        expect(receiver.mock.calls).toMatchObject([
            ["7"], ["0"]
        ]);
    })
});

现在,我可以只提供 2 参数或 1 到 N 参数形式的 Join ,一次只连接两个节点,然后写成Join(gasket, Join(gasket, Join(gasket, sink))等等......但我真的很想改善信号信噪比。

我错过了一个技巧,还是目前这根本不可能?

编辑:这是一个无错误的 TS 游乐场,展示了代码的展开类型版本。type Gaskets<I,O>可以替换为的其他定义来查看问题。

编辑:这是使用@jcalz 示例代码进行的重构Join(),它按我预期的方式工作,但在实现中存在一些意外错误。type OGaskets完全是巫术,虽然我可以跟随其他一切,但那一点,以及它对整体的贡献,目前对我来说是不可理解的。

标签: typescripttypescript-generics

解决方案


我将给出Gasket一些Sink默认的泛型类型参数,这样我就可以写成Gasket“任何可以分配给Gasket<I, O>forany Iany O”的东西。如果您不想这样做,您可以为这些类型命名,并使用它们来代替AnySinkAnyGasket这些主要用于约束。

interface Sink<T = any> { seal(): void; send(value: T): void; }
interface Gasket<I = any, O = any> { (target: Sink<O>): Sink<I> }

因此,尝试编写Gaskets<I, O>为任何特定类型的主要问题是,它最终会成为垫圈链之间和垫圈链中所有可能类型集的无限联合。这不能直接用 TypeScript 编写(没有microsoft/TypeScript#14466中要求的直接存在量化的泛型)。IO

相反,我们能得到的最接近的方法是编写一个类型,用于检查以水槽结尾的候选垫片链。如果这个候选人是G,那么AsChain<G>将代表类似于“最接近”的有效链G。如果Gs 是有效链,则AsChain<G>应该是 的超类型G。也就是说,当是一个有效链G extends AsChain<G>时将保持。G另一方面,如果G无效链,则不应该AsChain<G>是的超类型。所以当是一个无效链时不会持有。GG extends AsChain<G>G

这必然比Gaskets您想象的任何直接表示更复杂,并且可能很脆弱并且有奇怪的边缘情况。实际上,TypeScript 并不能以优雅的方式处理这些递归链。


无论如何,调用签名Join将如下所示:

function Join<G extends [...Gasket[], Sink]>(
  ...arg: G extends AsChain<G> ? G : AsChain<G>
): any // return type to come

因此,如果您调用Join(...arg),则类型参数G将被推断为arg. 如果G是一个有效的链,那么条件类型 G extends AsChain<G> ? G : AsChain<G>的计算结果是G,一切都很好。否则,如果G无效,则条件类型的计算结果为,AsChain<G>并且由于G不可分配给AsChain<G>,编译器将抱怨其中的某些元素G不适合 的相应元素AsChain<G>

顺便说一句,如果我们能写就太好了

// won't work
function Join<G extends AsChain<G>>(...arg: G ): any 

或者

// also won't work
function Join<G extends [...Gasket[], Sink]>(...arg: AsChain<G> ): any 

因为这两者在逻辑上都是相同的。但是这些都不适用于编译器的类型推断。编译器将无法推断G为与相同的类型,arg并且最终会抱怨甚至是有效的链。上面的条件类型G extends AsChain<G> ? G : AsChain<G>是我发现的唯一似乎表现良好的东西。


所以现在最大的问题是如何定义Aschain<G>. 这里的想法是查看候选链,[Gasket<I1, O1>, Gasket<I2, O2>, Gasket<I3, O3>, Sink<T>]并移动类型参数以确保它们正确链接为链。例如,[Gasket<any, O1>, Gasket<O1, O2>, Gasket<O2, O3>, Sink<O3>]。如果前者可分配给后者,那是因为I2可分配给O1,并且I3可分配给O2,并且T可分配给O3。如果其中一项分配失败,则链无效。

不幸的是,这样做需要很多元组类型的改组。以下是一些辅助类型,并简要说明了它们的作用。

Init<T>Last<T>采用元组类型T并拆分最后一个元素。 Init<[1,2,3]>是初始部分,[1,2],而Last<[1,2,3]>是最终元素,即3

type Init<T extends any[]> = T extends [...infer R, any] ? R : never;
type Last<T extends any[]> = T extends [...infer F, infer L] ? L : never;

OGasket<G>类型接受 a并Gasket<I,O>返回:O

type OGasket<G> = G extends Gasket<any, infer O> ? O : never;

OGaskets<G>类型提取链O中每个元素的一部分Gasket<I, O>,并将其移动到元组的下一个元素。所以OGaskets<[Gasket<I1, O1>, Gasket<I2, O2>, Gasket<I3, O3>, Sink<T>]>应该变成[any, O1, O2, O3]. 然后我们将使用这些成为I最终AsChain<G>元组的元素。这是实现:

type OGaskets<G extends [...Gasket[], Sink]> =
  Init<G> extends infer F ? 
    [any, ...Extract<{ [K in keyof F]: OGasket<F[K]> }, any[]>] : 
  never;

我正在删除G并切掉我们不需要的最后一个元素 ( ),并通过条件类型推断Init<G>将其复制到新类型参数中。然后,在初始之后,我将元组类型映射到另一个相同长度的元组,我们将在其中提取 each 的部分。包装器只是为了让编译器理解映射的元组将是可以通过可变元组扩展运算符扩展的东西。Finferany FOGasket<I, O>Extract< , any>

最后,AsChain<G>将元组映射G到其自身的新版本,其中每个IinGasket<I, O>都被替换为来自 的相应条目OGaskets<G>,而T最终的 inSink<T>被替换为来自 的最后一个条目OGaskets<G>

type AsChain<G extends [...Gasket[], Sink]> = 
  { [K in keyof G]: G[K] extends Gasket<any, infer O> ?
    Gasket<OGaskets<G>[K], O> : Sink<Last<OGaskets<G>>> }

你去吧。


现在Join具有以前的参数类型。由于您似乎打算Sink从 的第一个元素返回 a G,我们可以写出该返回类型:

function Join<G extends [...Gasket[], Sink]>(
  ...arg: G extends AsChain<G> ? G : AsChain<G>
): G[0] extends Gasket<infer I, any> ? Sink<I> : G[0];

请注意,编译器不可能理解任何函数实现都将符合那个复杂的通用条件类型。所以你可能不应该尝试。最简单的方法是使用上面的单个调用签名创建Join一个重载函数,

然后使实现非泛型:

function Join(...arg: [...Gasket[], Sink]): Gasket | Sink {
  return null!; // impl
}

它应该更容易实现,因为编译器只会检查上面更松散的非泛型类型。或者,您可以将实现的强类型化为function Join(...arg: any[]): any { /**/ }. 只要您有理由确定实现是安全的,您需要做的任何事情来停止编译器警告都是公平的游戏。


所以让我们测试一下:

declare const strToNum: Gasket<string, number>;
declare const numToBool: Gasket<number, boolean>;
declare const boolSink: Sink<boolean>;

const stringSink = Join(strToNum, numToBool, boolSink);
//const stringSink: Sink<string>

Join(strToNum, strToNum, boolSink); // error!   Type 'string' is not assignable to type 'number'.

Join(strToNum, boolSink); // error!   Type 'boolean' is not assignable to type 'number'.

const anotherBoolSink = Join(boolSink); // okay
// const anotherBoolSink: Sink<boolean>

这一切看起来都不错。编译器对有效调用感到满意Join,对无效调用不满意,当它不满意时,它会抱怨指出问题的合理错误消息。

Playground 代码链接


推荐阅读