TypeScript 函数内的有条件类型解析方式与顶层解析方式不同,

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

Bug报告

🔎 搜索词

回归条件类型在函数内部的行为表现不同

🕗 版本与回归信息

  • 在版本4.2.3和4.3.5之间发生了变化

⏯ Playground链接

Playground链接

💻 代码

{
  type A = Record<string, number> 
  type B = Record<'a' | 'b', number> 
  const x: A extends B ? true : false = false; // ok
}

function test() {
  type A = Record<string, number> 
  type B = Record<'a' | 'b', number> 
  const x: A extends B ? true : false = false; // error
}

🙁 实际行为

条件类型在全局作用域和局部作用域中的解析方式不同。

🙂 预期行为

两种情况都应该以相同的方式解析,最好是 false

其他细节

这似乎与TypeScript比较别名的方式有关,将其中一个 Record 类型 Package 在 Omit<Record<...>, never> 中会导致比较正常进行。
Playground链接

function test() {
  type A = Record<string, number> 
  type B = Record<'a' | 'b', number> 
  const x: A extends B ? true : false = false; // error
}

function test2() {
  type A = Omit<Record<string, number>, never>
  type B = Record<'a' | 'b', number> 
  const x: A extends B ? true : false = false; // ok
}

function test3() {
  type A = Record<string, number>;
  type B = Omit<Record<'a' | 'b', number>, never>
  const x: A extends B ? true : false = false; // ok
}

function test4() {
  type A = Omit<Record<string, number>, never>;
  type B = Omit<Record<'a' | 'b', number>, never>
  const x: A extends B ? true : false = false; // error
}
bwitn5fc

bwitn5fc1#

此外,在 4.2 之前,TypeScript允许将 Record<string, number> 分配给 Record<"a" | "b", number>,而不考虑作用域。(仅在比较 Record 别名类型时)

{
  type A = Record<string, number> 
  type B = Record<'a' | 'b', number> 
  const x: A extends B ? true : false = false; // error, A is assignable to B
}

因此,4.2 是唯一正确的版本。这可能是一个错误的回归?

yhived7q

yhived7q2#

这是一个关于可变性测量已知问题的不幸表现。
对于 Record<K, T> ,我们测量 K 为逆变,这是所有类型都正确的,但对于 stringnumbersymbol ,它们解析为索引签名,索引签名与常规属性(它们更像可选属性)的关系不同。根据可变性, Record<string, number>Record<'a' | 'b', number> 的子类型,因为 string'a' | 'b' 的超类型。然而,在结构上相关时, { [x: string]: number }{ a: number, b: number } 的超类型。
为了弥补这种差异,我们将出现在Map类型的键位置的类型参数标记为具有“不可信”的可变性,这导致我们在根据可变性关系时出现不相关的类型时,我们仍然在结构上关联它们。
净效果是:

declare let aa: { [x: string]: number }
declare let bb: { a: number, b: number }

aa = bb;  // Ok
bb = aa;  // Error

declare let ar: Record<string, number>;
declare let br: Record<'a' | 'b', number>;

ar = br;  // Ok (related structurally because of "unreliable" marker)
br = ar;  // Ok (permitted by variance, but error structurally)

我们可以通过将出现在Map类型的键位置的类型参数标记为“不可测量”,从而始终在结构上关联它们来修复这个问题。但是(a)这会产生负面性能影响,(b)从技术上讲,这会是一个破坏性的更改,因为毫无疑问存在依赖于当前行为的代码。我确信我们已经尝试过并拒绝了这种补救方法。
回到此问题中的原始示例,全局范围 AB 类型具有与它们关联的新别名符号,因此看起来*不是同一通用类型的示例。因此,我们在结构上关联它们。然而,局部的 AB 类型没有给出新的别名符号(出于无关的原因),因此它们似乎都是 Record<K, T> 的示例。因此,我们根据可变性关系关联它们。
4.2版本的行为发生了变化,因为那时我们引入了重新别名类型别名示例的能力。

tjrkku2a

tjrkku2a3#

哇...感觉如此错误,一个无证的内部实现细节,如影响可分配性。并不是说这种情况没有发生过(变化测量就是它的本质),但这个特别难以在没有揭开编译器的面纱的情况下提出理论依据。

e4yzc0pl

e4yzc0pl4#

从技术上讲,这将是一个破坏性的改变,因为毫无疑问存在依赖于当前行为的代码。
值得注意的是,我不会故意依赖任何可以概括为“A 可以不稳固地分配给 B ,但只是有时,取决于内部编译器实现细节”的行为,而仅仅移动代码可能会破坏它。如果事实证明我的代码意外地依赖于这种行为,并且一个记录的错误修复破坏了它,我甚至都不会感到沮丧。

gkn4icbw

gkn4icbw5#

我已经提到这是一个已知的问题。作为参考,它最早在 #29698 中被报告。我们甚至有一个测试来展示不一致性,这个测试是在 #30416 中引入的。那个 PR 最初试图将所有Map类型标记为“不可测量”,但后来我们退缩了,我认为是因为有太多的 DT 中断。我们可能需要再次尝试。
@weswigham 你有什么想法吗?

kyxcudwk

kyxcudwk6#

不用担心,我理解这是一个设计限制;真正困扰我的不是不健全,而是我试图找出下面的差异性的原因,以便我可以尝试为它发明一个理论解释,而不涉及掀开车顶😄
全局范围的A和B类型与它们关联的新别名符号,因此似乎并不是相同泛型类型的示例。因此,我们从结构上将它们关联起来。然而,局部的A和B类型(由于无关的原因)没有被赋予新的别名符号,因此它们似乎都是Record<K, T>的示例。因此,我们根据变异性将它们关联起来。

**编辑:**我刚刚意识到这可能只影响为泛型示例创建类型别名的情况,而直接用Record s(或其他任何东西)表示的内容将更一致地关联起来(即始终基于变异性)。在这种情况下,那还不算太糟糕。

vzgqcmou

vzgqcmou7#

你好,我在StackOverflow上提出了这个问题,并将此问题与大家分享。如果找不到解决这个问题的方法,那么至少可以在TypeScript假设泛型变体时添加一个警告,因为这种假设可能是错误的,并提供如何明确告诉TypeScript泛型变体的说明(使用in、out或in out注解)。也许可以让用户选择不使用泛型变体优化(例如,使用novar T注解来告诉TypeScript始终结构性地比较该泛型类型的示例)?我不认为这会是一个破坏性的更改。

f0brbegy

f0brbegy8#

@ahejlsberg 我不知道编译器中可变性部分的足够信息,所以也许这个答案是错误的,但我觉得将Map类型的键类型的可变性标记为不可测量是在解决症状而不是问题的根本原因。
如果我从你的评论中理解正确的话,这个问题源于Map类型生成的正常属性与索引签名之间的语义差异,后者具有“可选”语义,而前者具有“必需”语义,最终破坏了引用透明性。换句话说,这个问题似乎源于索引签名类型的自然扩展的不健全性,即(纯属假设):

type Foo = {
  [k: symbol | 'a']: string
}

目前会展开为(或等价于)

type Foo = {
  [k: symbol]: string
  a: string
}

在这种情况下,这两种表示法都不等价。实际上,以下类型等同于第一个:

type Foo = {
  [k: symbol]: string
  a?: string
}

(在直接指定的键签名中,这种不健全性目前通过错误1337避免,该错误禁止在索引签名中使用字面类型。)
因此,我觉得这个问题可以通过允许必需和可选的索引签名来更清晰地解决:

// equivalent to existing index signatures
type Foo = {
  [k: string]?: unknown
}

// required signature. note that a value for `Foo` can in this case not be provided,
// as it would have to return a value for every possible string index value,
// but that's not necessarily a problem. there are other empty types, e.g. `never`
type Foo = {
  [k: string]: unknown
}

// however it does allow us to provide an index signature that is equivalent to normal properties:
type Foo = {
  [k: 'a' | 'b']: unknown
}
// is equivalent to
type Foo = {
  a: unknown,
  b: unknown
}

// whereas the optional version would be equivalent to optional properties:
type Foo = {
  [k: 'a' | 'b']?: unknown
}
// is equivalent to
type Foo = {
  a?: unknown,
  b?: unknown
}

在这种情况下,Map类型只需在没有可选修饰符的情况下生成索引签名的必需版本,如果有可选修饰符则生成可选版本,并保持引用透明性。
这也将与 noUncheckedIndexedAccess 重叠,提供一种安全的方式来指定索引是否必需还是可选,而不是依赖于编译器选项。
当然,我们不能仅仅为了向后兼容性而完全改变索引签名语法的语义,但可以通过让语法默认为“可选”,并提供一个“必需”签名的语法来避免这种情况,例如:

// optional
type Foo = {
  [k: string]: unknown
}

// required
type Foo = {
  [k: string]!: unknown
}

相关问题