TypeScript 空合并运算符应该始终包含右操作数的类型,

lymgl2op  于 4个月前  发布在  TypeScript
关注(0)|答案(8)|浏览(61)

搜索词:
代码

一个玩具示例是这样的。

let foo: string = "";
foo = "bar" ?? 123;

这个显然没问题,因为 "bar" 总是为真。
然而,当你考虑在使用 Record 对象之前检查它们的真值时,这就变得有点问题了。

const elts = ["foo", "bar", "spam", "spam", "foo", "eggs"];
const counts: Record<string, number>;
for (const elt of elts) {
  // This really **should** raise an error.
  counts[elt] = counts[elt] ?? "zero";
  counts[elt] += 1;
}

预期行为:

应该引发错误。

实际行为:

奇怪的是,在严格模式下没有引发错误,但在非严格模式下引发了错误。

$ ./node_modules/.bin/tsc ./foo.ts 
foo.ts:5:3 - error TS2322: Type 'number | "zero"' is not assignable to type 'number'.
  Type '"zero"' is not assignable to type 'number'.

5   counts[elt] = counts[elt] ?? "zero";
    ~~~~~~~~~~~

Found 1 error.
$ ./node_modules/.bin/tsc --strict ./foo.ts
# No error, exits 0 and emits JS.

Playground链接:

Playground链接
切换 strictNullChecks 配置选项将显示问题。

相关问题:

qxsslcnc

qxsslcnc1#

这可能与或重复的 #13778 有关。
--strictNullChecks 为真时,编译器假定 counts[elt] 必须是类型 number,而它永远不会是 nullundefined。如果你想让编译器注意到 counts[elt] 可能为 undefined,那么你需要(目前)手动将 undefined 包含在属性类型中:

const counts: Record<string, number | undefined> = {};
for (const elt of elts) {
  counts[elt] = (counts[elt] ?? "zero"); // error now
  counts[elt] = (counts[elt] ?? 0) + 1; // okay
}

Playground
--strictNullChecks 为假时,编译器从不假定空合并会短路,因为它永远无法消除 nullundefined。请参阅此 PR 中的评论。

hk8txs48

hk8txs482#

看起来这种事情可能存在一些“右结合性”(我不确定正确的术语来描述我的意思)。
我的意思基本上是这样的:

string -> string
string ?? number -> string | number;
null ?? number -> number;

我认为算法大致如下:

  • 给定一个链 A ?? B ?? C ?? ...,取第一个对(空合并是左结合的,对吧?)
  • 解析 AB 的类型
  • 判断 A ?? B 的类型为 NonNull<A> | B
  • 递归
string ?? (string | null) ?? string[]
// Take first pair...
=> (NonNull<string> | (string | null)) ?? string[]
// Simplify the types...
=> (string | null) ?? string[]
// Recursively take first pair...
=> NonNull<string | null> | string[]
// Simplify the types...
=> string | string[]
// Done!
w8biq8rn

w8biq8rn3#

&& , ||! 运算符也存在类似的问题。

omqzjyyz

omqzjyyz4#

我刚刚遇到了这个问题,但我认为这是按照预期工作的。重新表述一下@jcalz发现的问题,问题描述中的示例展示了一个过于宽松的 Record 类型:

const counts: Record<string, number>;

// strictNullChecks: true
counts.foo // evaluated as (number)

// strictNullChecks: false
counts.foo // evaluated as (number | null | undefined)

在严格模式下,@typescript-eslint/no-unnecessary-condition 可以捕获 TS 在计算类型时的短路行为,并会告诉你:

const elts = ['foo', 'bar', 'spam', 'spam', 'foo', 'eggs'];
const counts: Record<string, number> = {};
for (const elt of elts) {
  counts[elt] = counts[elt] ?? 'zero'; /*
^^^^^^^^^^^
Unnecessary conditional, expected left-hand side of `??` operator to be possibly null or undefined. [eslint] */
  counts[elt] += 1;
}

如果你明确地将类型扩大以包含 null | undefined,你将看到松散模式中的错误:

const elts = ['foo', 'bar', 'spam', 'spam', 'foo', 'eggs'];
const counts: Record<string, number> = {};
for (const elt of elts) {
  counts[elt] = (counts[elt] as number | null | undefined) ?? 'zero'; /*
^^^^^^^^^^^
Type 'number | "zero"' is not assignable to type 'number'.  Type 'string' is not assignable to type 'number'. [ts] */
  counts[elt] += 1;
}

考虑到一个 linter 能够指出潜在的意外行为,我不知道TS端是否有什么需要修复的地方。

1u4esq0p

1u4esq0p5#

我尊重地不同意(显然 - 我创建了这个问题😄)。在TypeScript中使用过于宽松的Record类型是一种常见的习惯用法,我认为改变这种行为使得这种习惯用法更容易使用。

drnojrws

drnojrws6#

我认为以下几点可能与此问题有关:

function auto<T1, T2>(value: T1, fallback?: T2) {
    return value ?? fallback;
}
// Incorrectly typed to "unknown" even though the right hand path of `??` will never be taken in this case.
const f1 = auto(42);

function forced<T1, T2>(value: T1, fallback?: T2): T1 extends undefined | null ? NonNullable<T1> | T2 : T1 {
    return value ?? fallback as any;
}
// Correctly typed to "number".
const f2 = forced(42);

这在你的系统依赖于备用值时可能是重要的。在当前的 ?? 类型下,系统会强制你定义一个 T2 备用值,因为如果你不定义一个,那么它的 unknown 类型从可选变为非可选,即使在 T1 永远不可能是 nullundefined 的情况下,也会污染最终的返回类型。
从我的Angular 来看,似乎 T1 ?? T2 的类型可以更精确地表示为 T1 extends undefined | null ? NonNullable<T1> | T2 : T1,而不是它当前的 NonNullable<T1> | T2 值,但我猜想我可能在理解上有所遗漏?

mfuanj7w

mfuanj7w7#

为了记录,这个:

let foo: string = "";
foo ??= 123;

具有"预期"的行为。

62lalag4

62lalag48#

似乎通过打开noUncheckedIndexedAccess配置来解决。

相关问题