对象 & 记录

在 Type-level TypeScript 中,对象和记录是两种最常用的数据结构。它们是我们 type-level 算法的基础,了解它们的工作原理十分重要。我能肯定你早已使用过对象和记录 ,但我希望本章能加深你对它们代表的意义的理解,以及展示一些不常用的使用场景

type SomeObject = { key1: boolean; key2: number };

type SomeRecord = { [key: string]: number };

在我们开始之前,让我再次提醒你一下,我们上一章节讨论过的重要概念。在深入探讨类型和集合理论之间的关系之前,我们简要介绍了类型的五个主要类别 —— 基础类型、字面量类型、数据结构类型、联合类型和交集类型。

我们发现类型代表一组值。就像集合一样,类型可以包含其他类型,我们把这种关系称之子类型

1

最后,我们学习了两种特殊的类型: never - 空集, 和 unkown - 其它所有类型的超集

现在,让我们关注上一章节中提到的四种数据结构类型中的两种ObjectsRecords

type FourKindsOfDataStructures =
  | { key1: boolean; key2: number } // objects
  | { [key: string]: number } // records
  | [boolean, number] // tuples
  | number[]; // arrays

Object 类型

Object 类型定义 JavaScript 的对象集合。创建 Object 类型的语法与我们创建常规对象的方式非常相似:

type User = {
  name: string;
  age: number;
  role: "admin" | "standard";
};

事实上,它们是 JS 对象在 type-level 的等价物。就像 JS 对象一样,它可以包含我们想要的任意数量的属性,并且每个属性都由一个唯一的键索引。需要注意的是,每个键可以包含不同的类型: name 键包含 string 类型, age 键包含 numner 类型。

我们创建的 User 类型是具有nameage 和包含正确类型值的 role 属性的所有对象的集合:

// ✅ this object is in the `User` set.
const gabriel: User = {
  name: "Gabriel",
  role: "admin",
  age: 28,
};

// ❌
const bob: User = {
  name: "Bob",
  age: 45,
  // <- the `role` key is missing.
};

// ❌
const peter: User = {
  name: "Peter",
  role: "standard",
  age: "45" /* <- the `age` key should be of type `number`,
                   but it's assigned to a `string`. */,
};

但是具有额外属性的对象呢?它们可以分配给我们的 User 类型吗?

嗯,可以……也不可以。让我解释一下!

对象的可分配性

如果您尝试将对象分配给具有显式类型注释的变量,就像我们到目前为止所做的那样,TypeScript 将拒绝额外的属性:

const alice: User = {
  name: "Alice",
  age: 35,
  role: "admin",
  bio: "...",
/* ~~~~~~~~~
       ^  ❌ This doesn't type-check. */
};

这是你将得到的完整错误:

Object literal may only specify known properties, and 'bio' does not exist in type 'User'. (2322)

对象字面量指的是你使用大括号 {} 语法內联定义的对象。

如果我们将具有额外属性的,预先定义好的对象赋值给 alice 会报错吗?

const looksLikeAUser = {
  name: "Alice",
  age: 35,
  role: "admin" as const,
  bio: "...", // <- extra prop!
};

// ✅ This works just fine!
const alice: User = looksLikeAUser;

TypeScript 不会报错。

所以最终的答案是具有额外属性的对象可以分配给具有更少属性的对象,但是在对象被内联定义的上下文中,TypeScript 有额外的规则来确保我们不会错误地分配我们之后无法使用的属性,因为类型检查会禁止我们这样做。

2

这意味着,你不能保证某种类型的对象不包含额外的属性!1 一个 Object 类型是一个至少包含它所定义的所有属性的对象的集合。

现在,让我们看看,可以用 type-level 的对象做些什么?

读取属性

要访问一个属性的类型,我们可以使用方括号:

type User = { name: string; age: number; role: "admin" | "standard" };

type Age = User["age"]; // => number
type Role = User["role"]; // => "admin" | "standard"

但是点不会起作用!

type Age = User.age;
//             ^ ❌ syntax error!

没关系。无论如何,方括号表示法和点表示法在 value-level 上是等价的。

一次读取多个属性

您可能已经注意到,在 User["age"] 等表达式中,键 ("age") 是字面量类型。如果您尝试使用联合类型,你认为会发生什么?

type User = { name: string; age: number; role: "admin" | "standard" };

type NameOrAge = User["name" | "age"]; // => string | number

嗯,它是有效的!就好像我们同时访问 "name""age" 属性一样,然后会得到一个包含它们的类型的联合类型。从一个对象访问多个属性相当于分别访问每个属性,然后用它们的结果构造一个联合类型:

type NameOrAge = User["name"] | User["age"]; // => string | number

只是变得更简洁了!2

keyof 关键字

keyof 关键字可以让你获取对象类型中所有属性的联合。它可以放在任何对象之前:

type User = {
  name: string;
  age: number;
  role: "admin" | "standard";
};

type Keys = keyof User; // "name" | "age" | "role"

由于 keyof 返回字符串类型的联合,我们可以将它与方括号符号结合起来以获取此对象中所有值的类型的联合!

type User = {
  name: string;
  age: number;
  role: "admin" | "standard";
};

type UserValues = User[keyof User]; //  string | number | "admin" | "standard"

这是一个非常常见的用例,我们经常定义一个 ValueOf 泛型类型来抽象这个模式:

type ValueOf<Obj> = Obj[keyof Obj];

type UserValues = ValueOf<User>; //  string | number | "admin" | "standard"

泛型是 type-level 函数。现在,我们可以对任何对象类型重用这个逻辑! 🎉

可选属性

对象类型可以定义可选的属性。将属性设置为可选的方法是使用 ?: 键修饰符:

type BlogPost = { title: string; tags?: string[] };
//                                   ^ this property is optional!

// ✅ No `tags` property
const blogBost1: BlogPost = { title: "introduction" };

// ✅ `tags` contains a list of strings
const blogBost2: BlogPost = {
  title: "part 1",
  tags: ["#easy", "#beginner-friendly"],
};

但是为什么我们需要一些特殊的语法,它看起来像联合类型,如 T | undefined

type BlogPost = { title: string; tags: string[] | undefined };

这是因为如上定义的对象必须为其类型中存在的所有属性定义值。 TypeScript 会要求我们显式地为 undefined 分配一个 tags 属性:

const blogBost1: BlogPost = { title: "part 1" };
//             ^ ❌ type error: the `tags` key is missing.

// ✅
const blogBost2: BlogPost = { title: "part 1", tags: undefined };

必须将所有可选属性分配给 undefined 并不方便。所以告诉 TypeScript 可以省略某个属性是一种更好的方法!

使用 intersections (&) 合并对象类型

为了使我们的代码更加模块化,经常将类型定义拆分为多个对象类型。让我们将我们的 User 类型拆分为三个部分:

type WithName = { name: string };
type WithAge = { age: number };
type WithRole = { role: "admin" | "standard" };

现在我们需要一种方法将它们重新组合成一个类型。我们可以为此使用 intersection (&) :

type User = WithName & WithAge & WithRole;

type Organization = WithName & WithAge; // organizations don't have a role

我们在上一章中已经看到,intersection 创建的类型具有交集类型的所有属性,因此 User 的这个新定义等同于上面的 User

对象的交集和键的并集

等等,如果类型 {a: string, b: number}{a: string}{b: number} 的交集,**为什么这个类型还包含了它们键的并集 'a' | 'b' ?**🤔

我知道这让某些人感到困惑,另一部分人则觉得很符合直觉,让我来把这个说清楚一些。

原因如下:我们并不是对它们的键进行交集,而是对它们的子集合进行交集的操作

由于具有额外键的对象类型可分配给具有较少键的对象类型,因此在集合 {a: string} 中存在包含类型为 number 的键 b 的对象。在集合 {b: number} 中也有包含 string 类型的键 a 的对象。

因此集合 {a: string} 和集合 {b: number} 相交的结果是同时属于这两个集合的值,表示为 {a: string, b: number}

3

事实证明,两个对象类型的交集包含它们键的并集

type A = { a: string };
type KeyOfA = keyof A; // => 'a'

type B = { b: number };
type KeyOfB = keyof B; // => 'b'

type C = A & B;
type KeyOfC = keyof C; // => 'a' | 'b'

相反,两个对象类型的并集包含它们的键的交集:

type A = { a: string; c: boolean };
type KeyOfA = keyof A; // => 'a' | 'c'

type B = { b: number; c: boolean };
type KeyOfB = keyof B; // => 'b' | 'c'

type C = A | B;
type KeyOfC = keyof C; // => ('a' | 'c') & ('b' | 'c') <=> 'c'

另一种思考方式是,如果您有一个类型 A 或类型 B 的值,那么在这种情况下唯一可能出现的键是 c

这是一般规则:

keyof (A & B) = (keyof A) | (keyof B)
keyof (A | B) = (keyof A) & (keyof B)

这有点微妙,我希望这是有帮助的!

对象相交的特殊情况

使用 intersections 合并对象有两个特殊情况。

首先,intersections 递归地应用于所有对象属性,因此如果某个属性同时存在于两种类型上,它也会被相交

这可能会产生意想不到的结果。特别是如果共享属性包含彼此不重叠的类型。在这种情况下,它将产生 never 类型(也称为空集):

type WithName = { name: string; id: string };
type WithAge = { age: number; id: number };
type User = WithName & WithAge;

type Id = User["id"]; // => string & number <=> never

如果您确实知道您的对象没有重叠属性,那么使用 intersections 合并它们就完全没问题了。

其次,对象的交集有可能对性能产生影响3。如果您的类型定义是静态的(它们不依赖于类型参数),您可以使用 interfaceextends 关键字获得相同的结果:

interface User extends WithName, WithAge, WithRole {}
interface Organization extends WithName, WithAge {}

interface 具有更好的性能,但只能静态定义。例如,我们无法在 type-level 函数中创建它们。

记录类型

就像对象类型一样,记录也表示对象的集合。不同之处在于记录类型的所有键类型都是相同的

一个布尔值记录类型定义如下:

type RecordOfBooleans = { [key: string]: boolean };

您可以将其理解为“可分配给 string 的任何键都具有 boolean 类型的值”。

你也可以使用内置的 Record 泛型来定义记录类型:

type RecordOfBooleans = Record<string, boolean>;

Record 定义如下:

type Record<K, V> = { [key in K]: V };

Notice the in keyword. This is using a feature called Mapped Types, which we will cover in more detail in a dedicated chapter. Briefly, in lets us assign a type of value for every key in the union K.

注意 in 这个关键字。这是映射类型的功能,我们将在专门的章节中更详细地介绍。简而言之, in 让我们为联合 K 中的每个键分配一种类型的值。

在我们之前的示例中,我们将 string 类型作为 K 传递,但我们也可以使用 string 的联合类型:

type InputState = Record<"valid" | "edited" | "focused", boolean>;

或者不使用 Record

type InputState = { [key in "valid" | "edited" | "focused"]: boolean };

等价于:

type InputState = { valid: boolean; edited: boolean; focused: boolean };

对于 type-level 的程序,记录不算有趣的数据结构,它只能包含单一的类型,而对象却可以包含很多不同的类型。大多时候,我们都只需要提取记录中值的类型。我们可以使用 [string] 来实现:

type ValueType = RecordOfBooleans[string]; // => boolean

我们同时读取所有可分配给 string 类型的键。因为它们都包含 boolean,所以我们得到了boolean类型。

辅助函数

TypeScript 内置了多个非常有用的辅助函数来处理对象类型。我们将很快学习如何创建我们自己的函数,并使用映射类型以各种方式转换对象,但现在,让我们看看内置的可用函数。

Partial

Partial 泛型接受一个对象类型并返回另一个相同的类型,并把所有属性都变成可选的:

type Props = { value: string; focused: boolean; edited: boolean };

type PartialProps = Partial<Props>;
// is equivalent to:
type PartialProps = { value?: string; focused?: boolean; edited?: boolean };

Required

Required 泛型与 Partial 的作用相反。它接受一个对象并返回另一个相同的对象,并把所有属性都变成必需的:

type Props = { value?: string; focused?: boolean; edited?: boolean };

type RequiredProps = Required<Props>;
// is equivalent to:
type RequiredProps = { value: string; focused: boolean; edited: boolean };

Pick

Pick 泛型删除所有不能分配给作为第二个参数给出的键类型的键:

type Props = { value: string; focused: boolean; edited: boolean };

type ValueProps = Pick<Props, "value">;
// is equivalent to:
type ValueProps = { value: string };

type SomeProps = Pick<Props, "value" | "focused">;
// is equivalent to:
type SomeProps = { value: string; focused: boolean };

Omit

Omit 泛型删除所有可分配给作为第二个参数给出的类型的对象属性。它与 Pick 相反!

type Props = { value: string; focused: boolean; edited: boolean };

type ValueProps = Omit<Props, "value">;
// is equivalent to:
type ValueProps = { edited: boolean; focused: boolean };

type OtherProps = Omit<Props, "value" | "focused">;
// is equivalent to:
type OtherProps = { edited: boolean };

总结

尽管你之前可能已经了解对象类型和记录类型,但我希望本章能培养您对它们所代表的值类型的直觉。对象类型和记录类型是我们对更复杂用例和更高级概念的理解的基础。这个基础一定要扎实!

以下是我们介绍的一些概念:

  • 对象类型和记录都表示 JavaScript 对象集合
  • 对象类型是至少包含在此类型上定义的所有属性的对象集,但它们还可以包含更多属性。
  • 记录类型是所有属性共享相同类型的对象集合。
  • Intersections 让我们将对象“合并”到包含所有属性的类型中。
  • TypeScript 提供了几个内置函数,如 PartialRequiredPickOmit 来转换对象类型。

挑战!

让我们练习一下!如果您还没有准备好,请查看Types & Values章节中的“How challenges work”部分,了解如何应对这些挑战。

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

Footnotes

  1. 这就是为什么 Object.keys(...) 返回一个 string[] 而不是 (keyof Obj)[] 的原因。 (keyof Obj)[] 是不正确的,因为 Object.keys(...) 可能会返回不可分配keyof Obj 的字符串.

  2. 使用联合类型可以一次读取多个属性的真正原因是 type-level 表达式中 User["name"] 和 value-level 表达式中 user["name"]工作方式不同。TypeScript 不是在类型 User 中找到等于“name”的键,而是尝试在 User 中找到可分配给类型 “name”每个键

    如果我们写 User["name" | “age”],TS 将找到所有可分配给类型 "name" | "age" 的键,并返回它们的值类型。很酷吧?

  3. 过度使用 Intersection 类型可能影响类型检查的性能. 在上一章(Types are just data)中,我解释说 Intersection 类型是数据结构。它们不是将对象类型实际合并到一个实体中,而是将每个单独的对象类型保存在内存中的一种内部相交类型的列表中。正因为如此,类型检查器需要分配更多的内存,并且将花费更多的时间来检查对象类型是否可分配,因为它需要遍历这个内部列表来逐一检查每个对象类型。

    Interfaces 在这方面更好,因为它们实际上将类型定义合并到一个扁平结构中。它们的缺点是它们不能动态创建,比如从泛型中的类型参数创建。这就是为什么使用交集来合并对象仍然是一个需要知道的有用技巧!