数组 & 元组
在上一章学习了 Object 类型之后,我们来看看 Type-level TypeScript 第二重要的数据结构—— 元组。
这可能会让人感到惊讶,但元组类型在 type-level 上比数组类型有趣得多。事实上,它们是真正的 type-level 程序的数组 。在本章中,我们将了解它们为何如此有用以及如何使用它们的所有强大功能。让我们开始吧!
元组
元组类型定义具有固定长度的数组集,每个索引可以包含不同类型的值。例如,元组 [string, number]
定义了一组只包含两个值的数组,其中第一个值是一个 string
,第二个值是一个 number
。
元组类型本质上是类型列表!它们可以包含零个、一个或多个值,并且每个值都可以是完全不同的类型。与联合类型不同,元组中的类型是有序的并且可以多次出现。它们看起来就像 JavaScript 的数组,可以当作 type-level 的等价物:
type Empty = [];
type One = [1];
type Two = [1, "2"]; // types can be different!
type Three = [1, "2", 1]; // tuples can contain duplicates
读取元组索引
就像 JS 数组一样,您可以使用数字索引访问元组中的值:
type SomeTuple = ["Bob", 28];
type Name = SomeTuple[0]; // "Bob"
type Age = SomeTuple[1]; // 28
唯一的区别是元组是由数字字面量类型索引的,而不仅仅是数字。
读取多个索引
我们在对象&记录章节中提到,可以使用字符串字面量的联合类型从对象里同时读取多个值:
type User = { name: string; age: number; isAdmin: true };
type NameOrAge = User["name" | "age"]; // => string | number
我们可以使用数字字面量的联合类型对元组做同样的事情!
type SomeTuple = ["Bob", 28, true];
type NameOrAge = SomeTuple[0 | 1]; // => "Bob" | 28
我们可以使用 T[number]
语法,同时读取元组 T
中所有的索引:
type SomeTuple = ["Bob", 28, true];
type Values = SomeTuple[number]; // "Bob" | 28 | true
T[number]
本质上是 type-level 上把列表转为集合的方法。
还记得我们的技巧吗,使用 O[keyof O]
读取了对象类型 O
中的所有值,并返回了一个联合类型? T[number]
是对元组类型做同样的事情。
那我们能对元组类型使用 keyof
吗?
我们能使用 keyof
吗?
从技术上来说,是可以的,但是 keyof
不仅会返回所有索引,还会返回 Array
原型上所有方法的名称,如 map
、filter
、reduce
等等:
type Keys = keyof ["Bob", 28]; // "0" | "1" | "map" | "filter" | ...
const key: Keys = "map"; // ✅ 😬
keyof
对于元组类型来说并不是很使用,所以我们很少使用它。
拼接元组类型
就像 JS 数组一样,我们可以使用扩展运算符 ...
,将一个元组里的内容展开放到另一个元组中。
type Tuple1 = [4, 5];
type Tuple2 = [1, 2, 3, ...Tuple1];
// => [1, 2, 3, 4, 5]
下面是如何将 2 个元组类型合并在一起:
type Tuple1 = [1, 2, 3];
type Tuple2 = [4, 5];
type Tuple3 = [...Tuple1, ...Tuple2];
// => [1, 2, 3, 4, 5]
用 ...
创建的元组类型称之为可变元组类型。它们非常有用!一旦与条件类型等其它功能相结合,它将成为我们工具箱中最为强大的工具!
为索引命名
元组语法允许为索引命名。就像对象一样,名称在值之前,名称和值由冒号 :
分隔:
type User = [firstName: string, lastName: string];
命名索引在消除相同类型值的用途的歧义上,非常有用。它们帮助我们理解我们正在处理的数据类型,但它们不会影响类型检查器的行为。
可选的索引
元组类型,一个鲜为人知的特性是它们具有可选索引的能力!要将索引标记为可选,您只需要添加一个问号 ?
在它之后:
type OptTuple = [string, number?];
// ^ optional index!
const tuple1: OptTuple = ["Bob", 28]; // ✅
const tuple2: OptTuple = ["Bob"]; // ✅
const tuple3: OptTuple = ["Bob", undefined]; // ✅
// ^ we can also explicitly set it to `undefined`
数组类型
在 TypeScript 中,数组类型非常常见。它们表示长度未知的数组集合。它们的所有值必须共享相同的类型,但由于这种类型可以是联合类型,它们也可以表示混合值的数组:
在 TypeScript 中,数组类型可以通过两种等效的方式创建:在类型后添加方括号,如 number[]
或使用更明确的 Array<number>
泛型:
type Tags = string[];
type Users = Array<User>; // same as `User[]`
type Bits = (0 | 1)[];
由于数组类型中的所有值都具有相同的类型,因此数组并不包含大量类型级别的信息 —— 它们只是单一类型的包装器。在这方面,它们与 Records 非常相似:
// `Arrays` are similar to `Records`:
type BooleanRecord = { [k: string]: boolean };
type BooleanArray = boolean[];
BooleanRecord
和 BooleanArray
:
- 都有未知数量的键或索引
- 两者所包含的所有值都共享一种类型
在本课程接下来的章节中,我们将主要关注对象类型
和元组类型
。由于它们是我们熟知的旧对象和数组的 type-level 的等价物,我们将能够在我们已经熟悉的算法中使用它们,比如递归循环!
提取数组中的类型
就像元组类型一样,我们可以使用 number
类型读取数组中值的类型:
type SomeArray = boolean[];
type Content = SomeArray[number]; // boolean
混合数组类型和元组类型
自从引入 可变元组类型 后,我们可以使用扩展运算符 ...
来混合数组类型和元组类型。这允许我们创建表示具有任意数量值的数组类型,但在特定索引处具有一些固定的类型。
// number[] that starts with 0
type PhoneNumber = [0, ...number[]];
// string[] that ends with a `!`
type Exclamation = [...string[], "!"];
// non-empty list of strings
type NonEmpty = [string, ...string[]];
// starts and ends with a zero
type Padded = [0, ...number[], 0];
这非常适合捕获我们的代码的一些不变量,而且通常不会影响正确的输入。例如,法国的社会安全号码总是以 1
或 2
开头。我们可以使用以下类型对其进行编码:
type FrenchSocialSecurityNumber = [1 | 2, ...number[]];
Neat!
元组类型和函数参数
现在,如果我们结合可变元组类型、命名索引和可选索引,我们可以创建以下类型的元组:
type UserTuple = [name: string, age?: number, ...addresses: string[]];
感觉很熟悉吗?它看起来就像函数参数:
function createUser(name: string, age?: number, ...addresses: string[]) {}
我们也可以使用我们的 UserTuple
来对这个函数进行类型注释:
function createUser(...args: UserTuple) {
const [name, age, ...addresses] = args;
// ~~~~ ~~~ ~~~~~~~~~
// ^ ^ ^
// string number string[]
}
createUser("Gabriel", 29, "28 Central Ave", "7500 Greenback Ln"); // ✅
createUser("Bob"); // ✅ `age` is optional and addresses can be empty.
createUser("Alice", 0, false);
// ~~~~~ ❌ not a `string`!
如果你想在几个不同的函数之间共享参数类型,使用元组类型来对函数参数进行类型注释,会很方便:
function createUser(...args: UserTuple) {}
function updateUser(user: User, ...args: UserTuple) {}
或者如果您的函数有多个函数签名:
type Name =
| [first: string, last: string]
| [first: string, middle: string, last: string];
function createUser(...name: Name) {}
createUser("Gabriel", "Vergnaud"); // ✅
createUser("Gabriel", "Léo", "Vergnaud"); // ✅
createUser("Gabriel"); // ❌
createUser("Oups", "Too", "Many", "Names"); // ❌
Leading Rest Elements
你已经看到元组和函数参数看起来十分相似,你可能会认为我们总是可以用元组类型对函数参数进行类型的注释。
在大部分情况下是这样的,但这里有一个特殊情况,你无法使用常规的类型对函数参数进行注释 -- Leading Rest Elements 。它是元组类型才具有的特性,在其它元素前使用扩展运算符 ...
。
例如,让我们为 loadsh 里的 [zipWith]https://lodash.com/docs/4.17.15#zipWith() 函数添加类型注释。zipWith(...arrays, zipper)
接受多个数组和一个 zipper
函数,然后调用 zipper 函数遍历每一个数组,并压缩到一个数组里。
下面是为 zipWith
添加类型注释的一种可能方式:
type ZipWithArgs<I, O> = [
...arrays: I[][], // <- Leading rest element!
zipper: (...values: I[]) => O
];
declare function zipWith<I, O>(...args: ZipWithArgs<I, O>): O[];
// ^ The `declare` keyword lets us define a type
// without specifying an implementation
const res = zipWith(
[0, 1, 2, 3, 4],
[1930, 1987, 1964, 2013, 1993],
[149, 170, 186, 155, 180],
(index, year, height) => {
// index, year, and height are inferred as
// numbers!
return [index, year, height];
}
)
您无法使用常规参数类型注释此函数,因为 JavaScript 语法不支持 leading rest elements:
declare function zipWith<I, O>(
...arrays: I[][], /* ~~~
^ ❌ A rest parameter must be last in a parameter list */
fn: (...values: I[]) => O
): O[];
好消息是它们在 type-level 里得到了支持!
🤔 如果我的数组包含不同类型的值怎么办?
您可能已经注意到我们对 zipWith
进行类型注释的方式有点……简单化。
我们希望所有数组都包含相同类型的值,但我们的实际代码肯定也需要压缩不同类型的数组:
/**
* With arrays containing different types, We need to
* tell TypeScript to consider them as arrays of unions
* 👇 **/
const zipped = zipWith<number | string, string>(
[1, 2, 3],
["a", "b", "c"],
(num, char) => {
/** 👆
* both `num` and `char` are inferred as `number | string`.
* Ideally, `num` would be of type `number` and `char`
* of type `string`. **/
return "😭";
}
);
但是我们怎样才能使它正常工作呢?
好吧,我们只需要使用一些高级 type-level 的 TypeScript !
如果下面的代码块暂时不能理解,请不要担心。在接下来的章节中,您将学习如何读写这些复杂类型。敬请关注 😊
declare function zipWith<Lists extends [any[], ...any[]], O>(
...args: ZipWithArgs<Lists, O>
): O[];
type ZipWithArgs<Lists extends any[][], O> = [
...arrays: Lists,
zipper: (...values: ComputeValues<Lists>) => O
];
type ComputeValues<Lists> = {
[I in keyof Lists]: Lists[I] extends (infer Value)[]
? Value
: never
}
。。。
我喜欢我们在使用 zipWith 时不需要再次编写类型注释,并且仍然能获得完整的类型提示,因为我们已经告诉了 TypeScript 如何为我们推断类型!
🤔 zipWith 的实现是什么样的?
本章重点介绍类型,但如果您好奇的话,这里有一个 zipWith
函数的可能实现:
function zipWith<I, O>(...args: ZipWithArgs<I, O>): O[] {
// retrieve arrays and zipper arguments
const arrays = args.slice(0, length - 1) as I[][];
const zipper = args[args.length - 1] as (...values: I[]) => O;
// get the minimum length in the array of arrays
const minLength = Math.min(...arrays.map((a) => a.length));
// run the zipper function for each index
let output: O[] = Array.from({ length: minLength });
for (let i = 0; i < minLength; i++) {
output[i] = zipper(...arrays.map((a) => a[i]));
}
return output;
}
总结
在本章中,我们了解了 type-level 中真正数组——元组。我们已经学习了如何创建它们,如何读取它们的内容,以及如何合并它们以形成更大的元组!
我们还讨论了数组类型,它表示具有未知数量的值的数组,其中的值都共享相同的类型。数组和元组是互补的——我们可以在可变元组中将它们混合在一起。
练习时间!💪
与往常一样,让我们完成一些 challenges ,让我们的新技能发挥作用!