TypeScript 中的数组类型

关于Array(泛型语法)与string[](数组语法)这两种表示法在功能上绝对没有任何区别。选择使用哪种表示法似乎只是个人偏好。无论你选择哪一种表示法,请确保打开数组类型的eslint规则以保持一致性。

数组表示法的优点:

它更短

就是这样。这就是优势所在。写入的字符更少。好像代码越短就越易维护一样。let比const要短 - 这是否意味着我们到处都使用let?好吧,我也知道那场辩论,但我们不要再讨论那个话题。😂

但是说真的 - i比index短,d比dashboard短等等。某事之所以短,并不意味着它更好。我们知道我们阅读代码的频率远远超过编写它的频率,所以我们不应该把重点放在使其易于编写上 - 而应该使其易于阅读,这也就引出了泛型表示法的第一个优势:

可读性

我们通常从左到右阅读,所以更重要的东西应该放在前面。我们说:“这是一个字符串数组”,或者“这是一个字符串或数字数组”。

1
2
3
4
5
// ✅ reads nice from left to right
function add(items: Array<string>, newItem: string)

// ❌ looks very similar to just "string"
function add(items: string[], newItem: string)

如果数组中的类型比较长,特别是如果它是从某个地方推断出来的,这一点尤其重要。IDE通常使用数组表示法显示数组类型,因此有时,当我悬停在对象数组上时,我会得到这样的结果:

1
2
3
const options: {
[key: string]: unknown
}[]

对我来说,这读起来像options是一个对象,直到最后,我才能看到它实际上是一个数组。如果对象有很多属性,情况会变得更糟,因为这会使内容变得更长,并且会在弹出框中显示滚动条 - 使得几乎不可能看到末尾的 []。当然,这可能是一个工具问题,但如果我们将其显示为其实际情况:一个对象数组,那么这个问题就不存在了。即使在多行上分散显示时,它也不会变得更长:

1
2
3
const options: Array<{
[key: string]: unknown
}>

这不是数组表示法的唯一优势

只读数组

让我们面对现实 - 我们作为函数输入的大多数数组应该是只读的,以避免意外的变异。我也会在另一篇文章中涉及这个话题。如果你使用泛型表示法,你只需用ReadonlyArray替换Array并继续进行。如果你使用数组表示法,你必须将其拆分为两个部分:

1
2
3
4
5
// ✅ prefer readonly so that you don't accidentally mutate items
function add(items: ReadonlyArray<string>, newItem: string)

// ❌ "readonly" and "Array" are now separated
function add(items: readonly string[], newItem: string)

这并不是什么大问题,但是readonly是一个只能用于数组和元组的保留字,在我们有一个执行相同功能的内置实用程序类型时,这真的很奇怪。而且,在阅读时将readonly和[]拆分开来真的会影响流程。

这些问题只是预热,让我们来谈谈真正令人讨厌的事情:

联合类型

如果我们将add函数扩展到接受数字,那么我们会希望一个字符串或数字数组。使用泛型表示法没有问题:

1
2
// ✅ works exactly the same as before
function add(items: Array<string | number>, newItem: string | number)

然而,使用数组表示法,事情开始变得奇怪。

1
2
// ❌ looks okay, but isn't
function add(items: string | number[], newItem: string | number)

如果你不能立即发现错误 。它隐藏得很深,需要一些时间才能看到。让我们通过实际实现该函数并查看我们得到的错误来帮助理解:

1
2
3
4
// ❌ why doesn't this work 😭
function add(items: string | number[], newItem: string | number) {
return items.concat(newItem)
}

显示以下错误:

Type ‘string’ is not assignable to type ‘ConcatArray & string’ (2769)

对我来说这一点都不意味着什么。为了解决这个谜题:这与运算符优先级有关。 []的绑定比|运算符更强,因此现在我们已经使items成为string OR number[]类型。

我们想要的是:(string | number)[],带有括号,以使我们的代码正常工作。泛型版本没有这个问题,因为它无论如何都用尖括号将Array与其内容分开。

还不相信泛型语法更好吗?我有一个最后的论点:

keyof

让我们看一个相当常见的例子,我们有一个接受对象的函数,我们还想将可能的键的数组传递给同一个函数。如果我们想要实现一个类似于pick或omit的函数,我们就需要这个:

1
2
3
4
5
6
7
const myObject = {
foo: true,
bar: 1,
baz: 'hello world',
}

pick(myObject, ['foo', 'bar'])

我们只想允许已有的键作为第二个参数传递,那么我们应该怎么做呢?使用keyof类型运算符:

1
2
3
4
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: Array<keyof TObject>
)

当然,当我们为数组使用泛型语法时,一切都运行正常。但是如果我们将其更改为数组语法会发生什么呢?

1
2
3
4
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: keyof TObject[]
)

令人惊讶的是,这里没有错误,所以这应该是好的。没有错误甚至更糟,因为这里确实有一个错误 - 它只是在函数声明时不会显示出来 - 它会在我们尝试调用它时显示出来:

1
pick(myObject, ['foo', 'bar'])

现在产生的错误是:

Argument of type ‘string[]’ is not assignable to parameter of type ‘keyof TObject[]’.(2345)

这个消息甚至比之前的更加难以理解。当我在一个真实的代码库中第一次看到这个错误时,我盯着它看了好5分钟。为什么我的键不是字符串呢?

我试图左右更改一些东西,提取类型到类型别名以获取更好的错误,但没有成功。然后我想明白了:这不是另一个括号的问题吗?
直到今天,我也不知道对于 keyof TObject[] 来说什么是合法输入。我弄不清楚。我只知道定义我们想要的正确方式是:(keyof TObject)[]

1
2
3
4
function pick<TObject extends Record<string, unknown>>(
object: TObject,
keys: (keyof TObject)[]
)

也许这篇文章能够帮助说服社区认为泛型符号更好,也许有一种方式可以集体转向它作为我们使用和希望看到的默认选项。然后也许,仅仅也许,工具将会跟进。