尝试定义一个相互关联的类型的变量/数组



所以,我正在开发一个库来管理转换管道(从一个源,通过几个"垫圈",到接收器)......并遇到专门针对递归类型的问题。据说自 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>是错的,但至少不会提出错误......但是当我添加 spread-运算符...前缀时,它会在该令牌上Gaskets is not generic ts(2315),并在我声明type Gaskets本身的第一行Type alias Gaskets circularly references itself ts(2456)

我有一个临时解决方法,但它基本上意味着手动展开递归,因此支持有限数量的元素。在这里,它展开为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"]
]);
})
});

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

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

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

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

我将给出GasketSink一些默认的泛型类型参数,以便我可以编写Gasket来表示"任何可能分配给anyIanyOGasket<I, 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>编写为任何类型的特定类型的主要问题是,它最终会成为垫圈链中IO之间所有可能类型集的无限联合。 这不能直接在 TypeScript 中编写(没有 microsoft/TypeScript#14466 中要求的直接存在量化泛型)。

相反,我们能够得到的最接近的是编写一个类型,该类型充当对以水槽结尾的候选垫圈链的检查。 如果这个候选链是G,那么AsChain<G>将表示类似于"最接近"的有效链G。 如果Gs 是有效的链,则AsChain<G>应该是G的超类型。 也就是说,当G是有效链时,G extends AsChain<G>将成立。 另一方面,如果G无效链,则AsChain<G>不应该G的超类型。 因此,当G是无效链时,G extends AsChain<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>,编译器将抱怨某些不适合AsChain<G>相应元素的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 

因为两者在逻辑上是相同的。 但这两种方法都不适用于编译器的类型推断。 编译器将无法推断Garg的类型相同,最终甚至会抱怨有效的链。 上面带有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>类型取Gasket<I,O>并返回O

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

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

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>),并通过带有infer的条件类型推断将其复制到F的新类型参数中。 然后,在初始any之后,我将元组类型F映射到另一个相同长度的元组,在那里我们拉出每个Gasket<I, O>O部分。Extract< , any>包装器只是为了让编译器理解映射的元组将通过可变元组扩展运算符进行扩展。

最后,AsChain<G>将元组G映射到自身的新版本,其中Gasket<I, O>中的每个I都替换为OGaskets<G>中的相应条目,最终Sink<T>中的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具有以前的参数类型。 由于您似乎计划从G的第一个元素返回Sink,我们可以写出该返回类型:

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的有效调用感到满意,对无效调用不满意,当它不满意时,它会抱怨指向问题的合理错误消息。

操场链接到代码