【问题标题】:TypeScript: Hacking around unsoundness in a Map `getOrCreate` helper functionTypeScript:绕过 Map `getOrCreate` 辅助函数中的不健全性
【发布时间】:2021-08-17 03:15:40
【问题描述】:

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

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, '1', createB);
    mapGetOrCreate(m, '1', createC);
}

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

export function mapGetOrCreate2<K, V, V2 extends V>(map: Map<K, V>, key: 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 有没有比原来差的地方?

【问题讨论】:

    标签: typescript generic-variance


    【解决方案1】:

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


    为了让以后遇到这个问题的人清楚,这里的不合理之处在于,只要 U 可分配给 T,TypeScript 就允许将 Map&lt;K, U&gt; 分配给 Map&lt;K, T&gt;

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

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

    function technicallyUnsound<K, T, U extends T>(mapU: Map<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
    

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

    真的没有办法完全防止这种情况;不管你做什么,即使你可以硬化mapGetOrCreate(),你也可以随时使用这样的别名来绕过它:

    mapGetOrCreate2(m, "", createA) // error, but
    const n: Map<string, A> = m;
    mapGetOrCreate2(n, "", createA) // no error
    

    不过,考虑到这一点,强化mapGetOrCreate() 的选项有哪些?


    mapGetOrCreate() 遇到的真正问题是 TypeScript 的通用类型参数推断算法。让我们将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&lt;string, B&gt;也被认为是一个有效的Map&lt;string, A&gt;

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

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


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

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

    declare function g<T, U extends T>(map: Map<string, T>, 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
    

    另一种非官方方式是intersect the "non-inferential" locations with {},它“降低了该推理站点的优先级”。这不太可靠,仅适用于 X 类型,其中 X &amp; {} 不会缩小 X(因此 X 不能包含 undefinednull),但它也适用于这种情况:

    type NoInfer<T> = T & {};
    declare function g<T>(map: Map<string, T>, valueFn: (key: string) => NoInfer<T>): T;
    g(m, createA); // error! A is not assignable to type NoInfer<B>
    // function g<B>(map: Map<string, B>, valueFn: (key: string) => NoInfer<B>): B
    g(m, createB); // okay
    g(m, createC); // okay
    

    我知道的最后一种方法是使用conditional types 的评估是deferred for as-yet unresolved type parameters 的事实。所以NoInfer&lt;T&gt;最终会评估为T,但这会在类型推断发生后发生。它也适用于这里:

    type NoInfer<T> = [T][T extends any ? 0 : never];
    declare function g<T>(map: Map<string, T>, valueFn: (key: string) => NoInfer<T>): T;
    g(m, createA); // error! A is not assignable to type B
    // function g<B>(map: Map<string, B>, valueFn: (key: string) => B): B
    g(m, createB); // okay
    g(m, createC); // okay
    

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

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

    Playground link to code

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2019-02-24
      • 1970-01-01
      • 2016-03-14
      • 1970-01-01
      • 2020-10-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多