数组 & 元组

上一章学习了 Object 类型之后,我们来看看 Type-level TypeScript 第二重要的数据结构—— 元组

这可能会让人感到惊讶,但元组类型在 type-level 上比数组类型有趣得多。事实上,它们是真正的 type-level 程序的数组 。在本章中,我们将了解它们为何如此有用以及如何使用它们的所有强大功能。让我们开始吧!

元组

元组类型定义具有固定长度的数组集,每个索引可以包含不同类型的值。例如,元组 [string, number] 定义了一组只包含两个值的数组,其中第一个值是一个 string,第二个值是一个 number

1

元组类型本质上是类型列表!它们可以包含零个、一个或多个值,并且每个值都可以是完全不同的类型。与联合类型不同,元组中的类型是有序的并且可以多次出现。它们看起来就像 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 原型上所有方法的名称,如 mapfilterreduce 等等:

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 中,数组类型非常常见。它们表示长度未知的数组集合。它们的所有值必须共享相同的类型,但由于这种类型可以是联合类型,它们也可以表示混合值的数组:

2

在 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[];

BooleanRecordBooleanArray:

  • 都有未知数量的键或索引
  • 两者所包含的所有值都共享一种类型

在本课程接下来的章节中,我们将主要关注对象类型元组类型。由于它们是我们熟知的旧对象和数组的 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];

这非常适合捕获我们的代码的一些不变量,而且通常不会影响正确的输入。例如,法国的社会安全号码总是以 12 开头。我们可以使用以下类型对其进行编码:

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 ,让我们的新技能发挥作用!

Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...
Challenge
Solution
Reset
Loading...