网站搜索

TypeScript 的 tsc 报错:类型 BBB 上不存在属性 AAA


我很想知道为什么 const { liveCount }=customer 在以下情况下不起作用。tsc 抛出了 属性 'liveCount' 在类型 'Customer' 上不存在。(2339) 错误。它们之间有什么区别,我需要学习什么才能更好地理解这个问题?

使用了 TS 4.2.3。

type Thing = {
    name: string
}

type Person = Thing & {
    age: number
    address?: string
}

type Cat = Thing & {
    liveCount: number
}

type Customer = Person | Cat

function deliverTo(customer: Customer) {
    // Case A: OK
    const { liveCount } = { ...customer }

    // Case B: Not OK
    // const { liveCount } = customer
}

答案#1

你只能访问存在于联合类型所有成员上的属性。

以下行为是预期的:

declare const customer: Customer;
const livecount = customer.liveCount; // error!
// Property 'liveCount' does not exist on type 'Customer'
//  Property 'liveCount' does not exist on type 'Person'.

TypeScript 只会允许你在联合类型的值上访问某个属性键,如果该属性键在 联合类型的每个成员 中都已知存在。请参阅 TS 手册中的“使用联合类型”。在上面的例子中,你不能访问 customer.liveCount,因为在类型为 Person 的值上,没有已知存在名为 "liveCount" 的属性键。

所以对于你示例中的所有代码,我们应该预期会出现一个错误,提示 liveCount 在 Customer 上不存在。 事实上,你的“案例 A”,const { liveCount }={ ...customer } 并没有导致错误,这显然是 TypeScript 中的一个漏洞。 参见我关于这个问题提交的 microsoft/TypeScript#43174。 至少截至今天(2021 年 3 月 10 日),该漏洞计划在 TypeScript 4.3.1 中修复,所以如果在不久的将来,你在“案例 A”和“案例 B”中都看到相同错误,我也不会感到惊讶。


如果你在想“等等,customer.liveCount不是类型为number | undefined吗?”,那很可能是因为你的思考过程是这样的:

  • customer 要么是 Cat,要么是 Person。✔
  • 如果 customer 是一个 Cat,那么 customer.liveCount 将是一个 number。✔
  • 如果 customer 是一个 Person,那么 customer 根本不会有 liveCount 属性。 ❌
  • 如果 customer 没有 liveCount 属性,那么 customer.liveCount 将是 undefined。✔
  • 所以 customer.liveCount 要么是 number,要么是 undefined。 ❌
  • 所以 customer.liveCount 的类型是 number | undefined。❌

带有勾号(✔)的声明是正确的,但带有叉号(❌)的声明对于 TypeScript 来说是错误的。让我们关注一下最初出错的地方:

  • 如果 customer 是一个 Person,那么 customer 根本不会有 liveCount 属性。 ❌

这不是真的。TypeScript 中的对象类型是开放的或可扩展的。你可以向对象类型添加属性而不会违反原始类型。这是件好事,因为它使接口和类扩展能够形成类型层次结构。如果bar是Bar类型,并且Bar extends Foo,那么bar也是Foo类型。但它也有一些恼人的影响,就像我们在这里看到的那样。

Person 类型的值已知具有:一个名为 name 的属性,其类型为 string;一个名为 age 的属性,其类型为 number;以及可选的名为 address 的属性,如果存在,其类型为 string | undefined。但是,类型为 Person 的值 不能被认定为没有其他属性。具体来说,不能认定它缺少名为 liveCount 的属性。并且对于该键可能存在的属性类型没有任何约束。它可能是 number、string、Date 或任何类型。唯一安全的说法是它类似于 unknown 或 any。因此,我们必须将逻辑修改为类似如下内容:

  • customer 要么是 Cat,要么是 Person。✔
  • 如果 customer 是一个 Cat,那么 customer.liveCount 将是一个 number。✔
  • 如果 customer 是一个 Person,那么 customer 可能有一个 liveCount 属性,其类型可以是任意类型。 ✔
  • 所以 customer.liveCount 将会是 any。✔
  • 所以 customer.liveCount 将会是 number 或 any。✔
  • 所以 customer.liveCount 的类型是 any,并且一开始访问 customer 上的 liveCount 属性很可能是一个错误。 ✔

例如:

const undeadCount = {
  name: "Dracula",
  age: 1000,
  liveCount: false
}
const weirdPerson: Person = undeadCount;
deliverTo(undeadCount);

这里,undeadCount 是一个有效的 Person,因为它有一个 name 和一个类型正确的 age。它有一个类型为 boolean 的 liveCount 属性,但这并不使它不符合 Person 的条件,如我可以将它赋值给类型为 Person 的变量 weirdPerson 而不会出错所示。因此我也可以调用 deliverTo(undeadCount)。如果 deliverTo() 的实现期望 customer.liveCount 在不是 undefined 的情况下为 number 类型,可能会发生一些非常奇怪的情况。你甚至可能遇到运行时错误(例如,customer.liveCount?.toFixed(2))。


请注意,如果你有一种方法可以表示“一个 Customer 要么 完全 是 Cat,要么 完全 是 Person,在这两种情况下都不能存在我不知道的额外属性”,情况会有所不同。但 TypeScript 当前并不支持所谓的“精确类型”,并且长期以来在 microsoft/TypeScript#12936 上有一个实现它们的功能请求。因此,在没有合理方法来表示 Exact<Cat> | Exact<Person> 的情况下,可以做些什么呢?


如何改为进行

有不同的解决方法。你可以做的一件事就是简单地断言你的customer具有一个可选的liveCount属性,该属性要么是number,要么是undefined。当然,从技术上可能有人会传入一个undeadCount,但你实际上并不期望这种情况发生,也不想浪费时间去担心这样不太可能的事件。其中一种断言方法是这样的:

const { liveCount } = customer as Partial<Cat>; // okay

这里我们说的是 customer 将要么拥有来自 Cat 的属性,要么这些属性会缺失,因此是 undefined。这包括 liveCount。

这种变通方法的更详细版本是说 customer 是一个像这样定义的 ExclusiveCustomer:

type ExclusiveCustomer = {
    name: string;
    age: number;
    address?: string | undefined;
    liveCount?: undefined;
} | {
    name: string;
    liveCount: number;
    age?: undefined;
    address?: undefined;
};

你明确地说,如果 customer 是一个 Person,那么它就不具有任何 Cat 特有的属性,反之亦然。你甚至可以让编译器通过类似 ExclusiveUnion 这样的类型函数,从 Customer 类型计算出 ExclusiveCustomer,参考这个其他的 SO 问题:

const { liveCount } = customer as ExclusiveUnion<Customer>;

另一种变通方法是使用类型缩小,通过检查它的一些属性使 customer 被视为 Person 或 Cat。 从表面上看,做到这一点的最简单方法,与本答案前半部分关于开放类型的内容完全相悖:

const liveCount = "liveCount" in customer ? customer.liveCount : undefined;
// const liveCount: number | undefined

什么?是的,你可以将in运算符用作类型保护,它可以将customer从Customer缩小为Cat(如果"liveCount" in customer为真)或Person(如果为假)。这个类型保护是在 microsoft/TypeScript#15256 中实现的,并且其中的相关评论暗示人们需要某种方式来做到这一点,并且这足够表明意图,我们会允许他们这样做,即使从技术上来说是不安全的:

现实情况是,大多数联合类型已经正确地彼此分离,并且不会发生别名冲突以显现问题。写 in 测试的人不会写出“更好”的检查;在实际操作中,实际上发生的只是人们添加类型断言或将代码移动到同样不安全的用户定义类型谓词中。总体上,我认为这并不比现状更糟(而且更好,因为它会减少用户定义类型谓词的使用,这些谓词如果使用 in 编写,很容易出错,因为缺乏拼写检查)。

这就引出了下一种方法:通过返回类型谓词的函数编写你自己的类型保护。

function isCat(x: any): x is Cat {
  return x && typeof x.liveCount === "number" && typeof x.name === "string"; 😸
}

const liveCount = isCat(customer) ? customer.liveCount : undefined;
// const liveCount: number | undefined

通过编写 isCat() 并返回 x is Cat,我们是在告诉编译器,它可以使用 isCat() 将某个对象缩小为 Cat(如果为真)或不是 Cat 的某个对象(如果为假)。 当你调用 isCat(customer) 时,这意味着它可能是 Cat 或 Person。 我碰巧以最类型安全的方式实现了 isCat(),但我承担了这个责任,而编译器放弃了这个责任。 人们也可以同样轻松地编写

function isCat(x: any): x is Cat {
  return Math.random() < 0.5 // 🙀
}

编译器也不会察觉。所以就这样吧。


回顾

  • 你只能安全地访问在联合类型的所有成员上都存在的属性。
  • 你看到它能工作的行为是一个漏洞。不要指望它会一直如此。
  • 如果你假设 TypeScript 中的类型是精确的,可能会发生奇怪的坏事。
  • 但它们可能不会发生,所以你可以使用类型断言让编译器停止报错。
  • 或者你可以使用类型缩小来让编译器停止抱怨,安全性程度各不相同。

代码的 Playground 链接

相关文章