TypeScript:解决 Map`getOrCreate` 辅助函数中的不健全问题

问题描述

我有一个辅助函数,用于从地图中获取条目,如果它不存在则添加它。

export function mapGetorCreate<K,V>(map: Map<K,V>,key: K,valueFn: (key: K) => V): V {
    let value = map.get(key);
    if (value === undefined) {
        value = valueFn(key);
        assert(value !== undefined);
        map.set(key,value);
    }
    return value;
}

(Full code in TS playground)

TypeScript 在泛型类型变化方面的不健全最终导致了我的这个实际问题:

type A = {a: number};
type B = A & {b: string};
type C = B & {c: boolean};

declare function createA(): A;
declare function createB(): B;
declare function createC(): C;

function f(m: Map<string,B>,element: Base) {
    m.set('1',createA()); // TS error (good)
    m.set('1',createB());
    m.set('1',createC());

    mapGetorCreate(m,'1',createA); // missing error!
    mapGetorCreate(m,createB);
    mapGetorCreate(m,createC);
}

我发现向函数签名添加一个类型参数 (V2) 可以“修复”该问题:

export function mapGetorCreate2<K,V,V2 extends V>(map: Map<K,valueFn: (key: K) => V2): V {
    let value = map.get(key);
    if (value === undefined) {
        value = valueFn(key);
        assert(value !== undefined);
        map.set(key,value);
    }
    return value;
}

还是不行,但至少TypeScript不能自动推断类型,这是一个小小的改进。

问题:

  1. 有没有更好的方法来完成我正在做的事情?
  2. mapGetorCreate2 是否比原版更糟糕?

解决方法

您正在寻找microsoft/TypeScript#14829 中要求的非推论类型参数用法。对此没有官方支持,但是有很多技术可以实现这种效果,其中之一就是您已经在使用的技术。


为了让以后遇到这个问题的任何人都清楚,这里的不健全之处在于,每当 Map<K,U> 可分配给 {{1} 时,TypeScript 都允许将 Map<K,T> 分配给 U }:

T

这实际上是完全合理的,只要您只是读取地图,但是当您写入给它们时,您最终可能会遇到麻烦: >

function technicallyUnsound<K,T,U extends T>(mapU: Map<K,U>) {
    const mapT: Map<K,T> = mapU;
}

这就是 TypeScript 的方式;它具有一组有用的功能,例如对象可变性、子类型化、别名和 method bivariance,可以提高开发人员的生产力,但可以以不安全的方式使用。无论如何,请参阅 this SO answer 以了解有关此类健全性问题的更多详细信息。

真的没有办法完全阻止这种情况;无论您做什么,即使您可以强化 function technicallyUnsound<K,U>,k: K,t: T) { mapU.set(k,u); // error,as desired const mapT: Map<K,T> = mapU; mapT.set(k,t); // no error,uh oh } const m = new Map<string,Date>(); technicallyUnsound(m,"k",{}); m.get("k")?.getTime(); // compiles okay,but // RUNTIME ERROR: u is not defined ,您也始终可以使用这种别名来绕过它:

mapGetOrCreate()

不过,考虑到这一点,强化 mapGetOrCreate2(m,"",createA) // error,but const n: Map<string,A> = m; mapGetOrCreate2(n,createA) // no error 的选项有哪些?


您在使用 mapGetOrCreate() 时遇到的真正问题是 TypeScript 的通用类型参数推断算法。让我们将 mapGetOrCreate() 归结为函数 mapGetOrCreate(),我们忘记了键类型 g()(仅使用 K)。在以下调用中:

string

编译器推断类型参数declare function g<T>(map: Map<string,T>,valueFn: (key: string) => T): T; g(m,createA); // no error,not good // function g<A>(map: Map<string,A>,valueFn: (key: string) => A): A 应该由类型T指定,因为A返回一个valueFn,一个A也是被视为有效的 Map<string,B>

理想情况下,您希望编译器仅从 Map<string,A> 推断 T,然后检查 map 是否可分配给 valueFn(key: string) => T .在 T 中,您只想使用 valueFn 而不是推断它。

换句话说,您正在寻找非推论类型参数用法,正如 microsoft/TypeScript#14829 中所要求的那样。正如我在开头所说的,没有正式的方法可以做到这一点。


让我们看看非官方的方式:

一种非官方的方式是 use an additional type parameter T ,即 constrained 到原始类型参数 U。由于 T 将与 U 分开推断,因此从 T 的角度来看,它看起来是“非推断性的”。这就是您使用 T 所做的:

mapGetOrCreate2

另一种非官方方式是 intersect the "non-inferential" locations with {},它“降低了该推理站点的优先级”。这不太可靠,仅适用于 declare function g<T,U extends T>(map: Map<string,valueFn: (key: string) => U): T; g(m,createA); // error! A is not assignable to B // function g<B,B>(map: Map<string,B>,valueFn: (key: string) => B): B g(m,createB); // okay g(m,createC); // okay 不缩小 X 的类型 X & {}(因此 X 不能包含 Xundefined ),但它也适用于这种情况:

null

我所知道的最后一种方法是利用 conditional types 的求值是 deferred for as-yet unresolved type parameters 这一事实。所以 type NoInfer<T> = T & {}; declare function g<T>(map: Map<string,valueFn: (key: string) => NoInfer<T>): T; g(m,createA); // error! A is not assignable to type NoInfer<B> // function g<B>(map: Map<string,valueFn: (key: string) => NoInfer<B>): B g(m,createC); // okay 最终评估为 NoInfer<T>,但这会在类型推断发生后发生。它也适用于此:

T

所有这三种方法都是变通方法,并不适用于所有情况。如果你对它的细节和讨论感兴趣,你可以通读 ms/TS#14829。我在这里的主要观点是,如果您的技术适用于您的用例,那么它可能没问题,而且我不知道有任何明显优越的技术。

我认为修改版比原版更糟糕的唯一方式是它更复杂,需要更多测试。您试图避免的问题实际上在实践中似乎并不经常出现(这就是方法二元性是语言的一部分的原因);既然您实际上遇到了问题,那么您可能应该真正解决它,因此增加的复杂性是值得的。但是,由于面对不健全的类型系统,这种强化从根本上是不可能的,因此很快就会出现收益递减点,之后最好只接受不健全并编写一些更具防御性的运行时检查,并放弃尝试开拓一个纯粹健全的领域。

Playground link to code