Skip to content

Item 60: Know How to Iterate Over Objects

要点

  • Be aware that any objects your function receives as parameters might have additional keys.
  • Use Object.entries to iterate over the keys and values of any object.
  • Use a ++for-in++ loop with an explicit type assertion to iterate objects when you know exactly what the keys will be.
  • Consider Map as an alternative to objects since it's easier to iterate over.
  • 要注意函数接收的任何对象作为参数可能包含额外的键。
  • 使用 Object.entries 来遍历任何对象的键和值。
  • 当你确切知道对象的键时,使用 for-in 循环并进行显式的类型断言来遍历对象。
  • 考虑使用 Map 作为对象的替代品,因为它更容易进行迭代。

正文

这段代码运行正常,但 TypeScript 却标记了一个错误。为什么?

ts
const obj = {
  one: 'uno',
  two: 'dos',
  three: 'tres',
}
for (const k in obj) {
  const v = obj[k]
  //        ~~~~~~ Element implicitly has an 'any' type
  //               because type ... has no index signature
}

💻 playground

检查 objk 符号会给出线索:

ts
const obj = { one: 'uno', two: 'dos', three: 'tres' }
//    ^? const obj: {
//         one: string;
//         two: string;
//         three: string;
//       }
for (const k in obj) {
  //       ^? const k: string
  // ...
}

💻 playground

k 的类型是 string,但你试图索引一个类型只有三个特定键的对象:'one''two''three'。除了这三个之外还有其他字符串,所以这必须失败。

使用类型断言来获得 k 的更窄类型可以解决这个问题:

ts
for (const kStr in obj) {
  const k = kStr as keyof typeof obj
  //    ^? const k: "one" | "two" | "three"
  const v = obj[k] // OK
}

💻 playground

所以真正的问题是:为什么在第一个例子中 k 的类型被推断为 string 而不是 "one" | "two" | "three"

为了理解,让我们看一个稍微不同的例子:

ts
interface ABC {
  a: string
  b: string
  c: number
}

function foo(abc: ABC) {
  for (const k in abc) {
    //       ^? const k: string
    const v = abc[k]
    //        ~~~~~~ Element implicitly has an 'any' type
    //               because type 'ABC' has no index signature
  }
}

💻 playground

这是和之前一样的错误。你可以使用相同类型的类型断言来"修复"它(k as keyof ABC)。但在这种情况下,TypeScript 的抱怨是正确的。原因如下:

ts
const x = { a: 'a', b: 'b', c: 2, d: new Date() }
foo(x) // OK

💻 playground

函数 foo 可以用任何可赋值给 ABC 的值调用,而不仅仅是具有 'a''b''c' 属性的值。完全有可能该值还有其他属性(参见 Item 4 了解原因)。为了允许这种情况,TypeScript 给 k 它唯一能确定的类型,即 string

使用类型断言到 keyof ABC 在这里还有另一个缺点:

ts
function foo(abc: ABC) {
  for (const kStr in abc) {
    let k = kStr as keyof ABC
    //  ^? let k: keyof ABC (equivalent to "a" | "b" | "c")
    const v = abc[k]
    //    ^? const v: string | number
  }
}

💻 playground

如果 "a" | "b" | "c"k 来说太窄,那么 string | numberv 来说肯定也太窄。在前面的例子中,其中一个值是 Date,但它可能是任何东西。这可能导致运行时的混乱。正如 Item 9 所解释的,类型断言应该总是让你紧张,因为 TypeScript 可能发现了什么。(令人惊讶的是,TypeScript 会让你在这个 for-in 循环上方声明 let k: keyof ABC 并使用 k 作为迭代器,但这并不比类型断言更安全,而且不够明确。)

那么如果你只想遍历对象的键和值而不出现类型错误怎么办?Object.entries 让你可以同时遍历两者:

ts
function foo(abc: ABC) {
  for (const [k, v] of Object.entries(abc)) {
    //        ^? const k: string
    console.log(v)
    //          ^? const v: any
  }
}

💻 playground

虽然这些类型可能很难处理,但它们至少是诚实的!

TypeScript 在 for-in 循环中推断 string 的另一个原因是原型污染。这是一个安全问题,其中定义在 Object.prototype 上的属性被所有其他对象继承。这些继承的属性将被 for-in 循环枚举,所以 string 是一个更安全的选择。(Object.entries 排除了继承的属性。)

获得更精确类型的安全方法是明确列出你感兴趣的键:

ts
function foo(abc: ABC) {
  const keys = ['a', 'b', 'c'] as const
  for (const k of keys) {
    //       ^? const k: "a" | "b" | "c"
    const v = abc[k]
    //    ^? const v: string | number
  }
}

💻 playground

如果你的意图是覆盖 ABC 中的所有键,你需要某种方式来保持键数组与类型同步。

虽然遍历对象有很多危险,但遍历 Map 则没有:

ts
const m = new Map([
  //  ^? const m: Map<string, string>
  ['one', 'uno'],
  ['two', 'dos'],
  ['three', 'tres'],
])
for (const [k, v] of m.entries()) {
  //        ^? const k: string
  console.log(v)
  //          ^? const v: string
}

💻 playground

Map 更容易遍历,因为它们没有与对象相同的结构行为:你永远不会在不使用类型断言或通过 any 类型的情况下在 Map<string, string> 中放入数字值。但如果你的数据来自 JSON 或已经设计为使用对象的另一个 API,它们可能不太方便使用。Item 16 有一个例子,说明如何用 Map 替换对象类型可以提高代码的类型安全性。

如果你想遍历不可变对象中的键和值,你可以在 for-in 循环中对键使用显式类型断言。要安全地遍历可能具有额外属性的对象,请使用 Object.entries。它总是安全的,尽管键和值类型更难处理。并考虑 Map 是否可能是合适的替代方案。

要点回顾

  • 要注意函数接收的任何对象作为参数可能包含额外的键。
  • 使用 Object.entries 来遍历任何对象的键和值。
  • 当你确切知道对象的键时,使用 for-in 循环并进行显式的类型断言来遍历对象。
  • 考虑使用 Map 作为对象的替代品,因为它更容易进行迭代。

Released under the MIT License.