TypeScript 对 undefined 的不一致检查——为什么我在这里需要一个感叹号?

TypeScript inconsistent check for undefined -- Why do I need an exclamation point here?

我正在尝试了解自动类型规则。在这个例子中我

  1. 有一个带有可选参数的函数。
  2. 检查它是否未定义,如果是,请填写一个新值。
  3. 使用它。
  4. 最后 return 它。

第 1、2 和 4 步按预期工作。在第 4 步,typescript 清楚地知道不能取消定义“map”参数。但在第 3 步,我必须明确添加一个!否则我会收到一条错误消息。

代码有效(现在我已经添加了 exclamation/assertion)但它没有意义。这是 TypeScript 的正确行为吗?我在我的代码中做错了什么吗? 3 和 4 都指的是同一个变量,而 2 是在它们之前完成的,所以我看不出有什么区别。

function parseUrlArgs(inputString: string, map?: Map<string, string>) : Map<string, string> {
  if (!map) {
    map = new Map();
  }
  //map = map??new Map();  // This has the exact same effect as the if statement, above.
  // Note:  JavaScript's string split would not work the same way.  If there are more than two equals signs, String.split() would ignore the second one and everything after it.  We are using the more common interpretation that the second equals is part of the value and someone was too lazy to quote it.
  const re = /(^[^=]+)=(.*$)/;
  // Note:  trim() is important on windows.  I think I was getting a \r at the end of my lines and \r does not match ".".
  inputString.trim().split("&").forEach((kvp) => {
    const result = re.exec(kvp);
    if (result) {
      const key = decodeURIComponent(result[1]);
      const value = decodeURIComponent(result[2]);
      map!.set(key, value);  // Why do I need this exclamation mark?
    }
  });
  return map;
}

我没有更改任何 TypeScript 设置。我使用的是 Deno 内置的默认设置,列出 here. I got similar results in the typescript playground.

打字稿出现问题的原因是您将新地图分配给同一个 map 变量。它已经确定 map 的类型是 Map<string, string> | undefined 并且该类型在整个过程中得到保留。

简单的解决方法是在函数签名中创建新的 Map,这样 map 永远不会是 undefined

function parseUrlArgs(
    inputString: string, 
    map: Map<string, string> = new Map()
): Map<string, string> {

这里的问题是 TypeScript 中的一个限制:控制流分析(如 microsoft/TypeScript#8010) does not propagate into or out of function scope boundaries. See microsoft/TypeScript#9998 for details and discussion. There is also a more specific issue, microsoft/TypeScript#11498 中所实现和描述的,这表明能够“内联”某些类型的回调的控制流分析。


编译器分析代码块if (!map) { map = new Map(); },成功理解到在这个块之后,map肯定不是undefined,你可以尝试使用[=的方法来证明15=] 在该代码块之前和之后:

map.has(""); // error
if (!map) {
  map = new Map();
}
map.has(""); // okay

一切顺利,直到您进入回调函数的主体,跨越函数范围的边界:

[1, 2, 3].forEach(() => map.has("")); // error, map might be undefined

编译器确实不知道何时或是否会调用该回调。 知道数组 forEach() 对数组中的每个元素同步运行一次回调。但是编译器不知道这一点,甚至不知道如何在类型系统中表示这一点(没有实现某种方法来跟踪函数对其回调所做的操作,如 microsoft/TypeScript#11498 中所建议的那样)

假设您看到了一个函数 foobar(() => map.has(""))。您是否知道何时或是否在没有找到 foobar() 的实现并检查它的情况下调用该回调?这就是编译器对 forEach().

的看法

编译器认为回调可能会在其先前的控制流分析不再适用的某个点被调用。 “也许 map 在外部函数的其他后面部分被设置为 undefined” 因此,它放弃并将 map 视为可能 undefined。同样,you 知道情况并非如此,因为 map 超出范围而没有被 deleted 或 map = undefined 完成.但是编译器不会花费必要的周期来解决这个问题。放弃是一种 trade-off,其中性能比完整性更重要。


当您意识到编译器只是假定 closed-over 值不会在回调函数中被 修改 时,情况会变得更糟。正如没有来自外部作用域的控制流分析向内传播一样,没有来自内部作用域的控制流分析向外传播:

[4, 5, 6].forEach(() => map = undefined); 
return map; // no error?!

在上面的代码中,当您到达 return map 时,map 肯定会成为 undefined,但编译器允许它没有警告。为什么?同样,编译器不知道回调将被调用或何时被调用。在定义或调用闭包后丢弃所有控制流分析结果会 更安全 ,但这会使控制流分析几乎无用。尝试内联回调需要了解 forEach()foobar() 的不同之处,并且涉及大量工作,并且可能导致编译器速度变慢。假装回调不影响控制流分析是 trade-off,其中性能和便利性比稳健性更重要。


那么可以做什么呢?一件简单的事情是将您的值分配给发生控制流分析的范围内的 const 变量。编译器知道 const 变量永远无法重新分配,并且它知道(好吧,假装)这意味着变量的类型也永远不会改变:

function parseUrlArgs(inputString: string, map?: Map<string, string>): Map<string, string> {
  if (!map) {
    map = new Map();
  }
  const resultMap = map; // <-- const assignment here
  const re = /(^[^=]+)=(.*$)/;
  inputString.trim().split("&").forEach((kvp) => {
    const result = re.exec(kvp);
    if (result) {
      const key = decodeURIComponent(result[1]);
      const value = decodeURIComponent(result[2]);
      resultMap.set(key, value); // <-- use const variable here
    }
  });
  return resultMap; // <-- use const variable here
}

通过在已知定义了 map 的位置将 map 复制到 resultMap,编译器知道 resultMapMap<string, string> 类型而不是 undefined。这种类型在函数的其余部分持续存在,甚至在回调内部也是如此。这可能有点多余,但编译器可以跟踪它并且相对类型安全。

或者您可以继续使用 non-null 运算符 !。由你决定。

Playground link to code