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

TypeScript: Hacking around unsoundness in a Map `getOrCreate` helper function

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

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 比原版差吗?

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


为了让以后遇到这个问题的人清楚一点,这里的不合理之处在于 TypeScript 允许 Map<K, U> 可以分配给 Map<K, T> 只要 U 可以分配给 T:

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, that allow for increased developer productivity but can be used in unsafe ways. Anyway, see 以获取有关此类稳健性问题的更多详细信息。

确实没有办法完全避免这种情况;不管你做什么,即使你可以加固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 returns 一个 A,一个 Map<string, B> 也被认为是有效的 Map<string, A>.

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

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


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

一种非官方的方式是use an additional type parameter U which is constrained到原始类型参数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 & {} 不会缩小 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 is deferred for as-yet unresolved type parameters 的评估。所以 NoInfer<T> 最终 评估为 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。我在这里的主要观点是,如果您的技术适用于您的用例,那么它可能没问题,而且我不知道有任何明显更好的技术。

我认为修改后的版本比原始版本差的唯一原因是它更复杂并且需要更多测试。您试图避免的问题实际上在实践中似乎并不经常出现(这就是方法双变性是语言的一部分的原因);因为你实际上 运行 陷入了这个问题,那么你可能应该真正解决它,所以增加的复杂性是值得的。但是,由于面对不健全的类型系统,这种加固从根本上是不可能的,因此很快就会出现一个递减点 returns,之后最好接受不健全并编写一些更具防御性的运行时检查,然后放弃试图开辟一片纯正的领土。

Playground link to code