第 15 章 类型操作

Flying
2023-07-28 / 0 评论 / 81 阅读 / 正在检测是否收录...

条件、映射
拥有超越类型的强大力量f
随之而来的是极大的困惑

TypeScript 在类型系统中为我们提供了强大的能力来定义类型。即使是在第 10 章“泛型”中介绍的逻辑修饰符与本章中的类型操作相比也显得相形见绌。一旦你完成了本章的学习,你将能够根据其他类型进行类型的混合、匹配和修改,为你在类型系统中表示类型提供强大的方法。

大多数这些高级类型技巧通常并不需要经常使用。你需要理解它们在哪些情况下有用,但请注意:当过度使用时,它们可能会难以理解。祝你学习愉快!

映射类型

TypeScript 提供了一种基于其他类型的属性来创建新类型的语法:换句话说,从一个类型到另一个类型的映射。在 TypeScript 中,映射类型是一种接受另一个类型并对该类型的每个属性执行某些操作的类型。

映射类型通过在一组键中的每个键下创建一个新属性来创建新类型。它们使用类似于索引签名的语法,但不是使用像 [i: string] 这样的静态键类型和 :,而是使用从其他类型计算出的类型,使用 in,例如 [K in OriginalType]

type NewType = {
  [K in OriginalType]: NewProperty;
};

一个常见的使用情况是,使用映射类型来创建一个对象,其中的键是现有联合类型中的每个字符串字面量。以下的AnimalCounts类型创建了一个新的对象类型,其中的键是来自 Animals 联合类型的每个值,而每个值都是number类型的:

type Animals = "alligator" | "baboon" | "cat";


type AnimalCounts = {
  [K in Animals]: number;
};
// 相当于:
// {
//   alligator: number;
//   baboon: number;
//   cat: number;
// }

基于现有联合类型的映射类型是在声明大型接口时节省空间的便捷方式。但是,当映射类型能够作用于其他类型,甚至可以添加或删除成员的修饰符时,映射类型真正发挥出色。

类型映射类型

映射类型通常会使用 keyof 运算符来操作现有类型的键。通过指示类型映射到现有类型的键,我们可以从现有类型到新类型进行映射。

这个AnimalCounts 类型最终与之前的 AnimalCounts 类型相同,通过从 AnimalVariants 类型到一个新的等价类型进行映射:

interface AnimalVariants {
  alligator: boolean;
  baboon: number;
  cat: string;
}

type AnimalCounts = {
  [K in keyof AnimalVariants]: number;
};
// 相当于:
// {
//   alligator: number;
//   baboon: number;
//   cat: number;
// }

新类型的键在之前的代码片段中使用 keyof 进行映射,命名为 K。这意味着每个映射类型成员的值可以引用基本类型相应成员在相同键下的值。

如果原始对象是 SomeName,映射是 [K in keyof SomeName],那么每个映射类型成员都可以引用等效的 SomeName 成员的值,即 SomeName[K]

这个 NullableBirdVariants 类型接受一个原始的 BirdVariants 类型,并在每个成员上添加了 | null

interface BirdVariants {
  dove: string;
  eagle: boolean;
}

type NullableBirdVariants = {
  [K in keyof BirdVariants]: BirdVariants[K] | null
};
// 相当于:
// {
//   dove: string | null;
//   eagle: boolean | null;
// }

与手动从基本类型复制每个字段到任意数量的其他类型不同,映射类型允许你只需定义一组成员,然后根据需要批量重新创建它们的新版本。

映射类型和签名

在第 7 章“接口”中,我介绍了 TypeScript 提供了两种声明接口成员为函数的方式:

  • 方法语法,例如 member(): void:声明接口的成员是一个函数,旨在作为对象的成员进行调用
  • 属性语法,例如 member: () => void:声明接口的成员等于一个独立的函数

映射类型不区分对象类型上的方法和属性语法。映射类型将方法视为基本类型上的属性。

这个 ResearcherProperties 类型包含了 Researcher 接口的 propertymethod 成员:

interface Researcher {
  researchMethod(): void;
  researchProperty: () => string;
}

type JustProperties<T> = {
  [K in keyof T]: T[K];
};

type ResearcherProperties = JustProperties<Researcher>;
// 相当于:
// {
//    researchMethod: () => void;
//    researchProperty: () => string;
// }

在大多数实际的 TypeScript 代码中,很少会出现方法和属性之间的区别。很少有实际的使用场景需要使用映射类型来处理类类型。

更改修饰符

映射类型还可以改变基本类型成员的访问控制修饰符,例如 readonly?(可选)。可以使用与常规接口相同的语法,在映射类型的成员上放置 readonly?

下面的 ReadonlyEnvironmentalist 类型创建了一个 Environmentalist 接口的版本,其中所有成员都被设为 readonly。而OptionalReadonlyConservationist 更进一步,创建了另一个版本,给所有 ReadonlyEnvironmentalist 的成员添加了 ?

interface Environmentalist {
  area: string;
  name: string;
}

type ReadonlyEnvironmentalist = {
  readonly [K in keyof Environmentalist]: Environmentalist[K];
};

// 相当于:
// {
//   readonly area: string;
//   readonly name: string;
// }

type OptionalReadonlyEnvironmentalist = {
  [K in keyof ReadonlyEnvironmentalist]?: ReadonlyEnvironmentalist[K];
};
// 相当于:
// {
//   readonly area?: string;
//   readonly name?: string;
// }
OptionalReadonlyEnvironmentalist 类型也可以使用 readonly [K in keyof Environmentalist]?: Environmentalist[K] 的方式进行书写。

删除修饰符的操作是在新类型中在修饰符前添加 -。例如,可以写成 -readonly-?:,而不是 readonly?:

这个 Conservationist 类型包含了 ? 可选和 readonly 成员,这些成员在 WritableConservationist 中被设为可写,并在RequiredWritableConservationist 中被设为必需:

interface Conservationist {
  name: string; 
  catchphrase?: string; 
  readonly born: number; 
  readonly died?: number;
}

type WritableConservationist = {
  -readonly [K in keyof Conservationist]: Conservationist[K];
};
// 相当于:
// {
//   name: string;
//   catchphrase?: string;
//   born: number;
//   died?: number;
// }

type RequiredWritableConservationist = {
  [K in keyof WritableConservationist]-?: WritableConservationist[K];
};
// 相当于:
// {
//   name: string;
//   catchphrase: string;
//   born: number;
//   died: number;
// }
RequiredWritableConservationist 类型可以选择性的写成 readonly [K in keyof Environmentalist] ?: Environmentalist[K]

泛型映射类型

泛型映射类型的真正威力在于将它们与泛型结合使用,使得一种类型映射能够在不同类型之间被重用。映射类型能够访问其作用域中任何类型名称的 keyof,包括映射类型本身的类型参数。

泛型映射类型经常用于表示数据在应用程序中流动时的变形情况。例如,一个应用程序的某个区域可能希望接收现有类型的值,但不允许修改数据。

这个 MakeReadonly 泛型类型接受任何类型,并创建一个新版本,其中所有成员都添加了readonly修饰符:

type MakeReadonly<T> = {
  readonly [K in keyof T]: T[K];
}

interface Species {
  genus: string; 
  name: string;
}

type ReadonlySpecies = MakeReadonly<Species>;
// 相当于:
// {
//   readonly genus: string;
//   readonly name: string;
// }

开发人员常常需要表示的另一种转换是一个函数,它接受任意数量的接口,并返回一个完全填充了该接口的实例。

以下 MakeOptional 类型和 createGenusData 函数允许提供任意数量的 GenusData 接口,并返回一个填充了默认值的对象:

interface GenusData {
  family: string; 
  name: string;
}

type MakeOptional<T> = { 
  [K in keyof T]?: T[K];
}
// 相当于:
// {
//     family?: string;
//     name?: string;
// }

/**
* 对 GenusData 的默认值之上展开任何 {overrides}。
*/
function createGenusData(overrides?: MakeOptional<GenusData>): GenusData {
  return {
    family: 'unknown', 
    name: 'unknown',
    ...overrides,
  }
}

一些常用的泛型映射类型操作非常有用,TypeScript 提供了一些内置的实用类型来实现这些操作。例如,使用内置的 Partial<T> 类型可以使所有属性变为可选。你可以在https://www.typescriptlang.org/docs/handbook/utility-types.html上找到这些内置类型的列表。

条件类型

将现有类型映射到其他类型是非常方便的,但我们还没有将逻辑条件添加到类型系统中。现在让我们来做这个。

TypeScript 的类型系统是一个逻辑编程语言的例子。它允许根据先前的类型进行逻辑检查来创建新的构造(类型)。它通过条件类型的概念来实现,条件类型根据现有类型的情况解析为两种可能的类型之一。

条件类型的语法类似于三元运算符:

LeftType extends RightType ? IfTrue : IfFalse

条件类型中的逻辑检查始终是检查左侧类型是否扩展或可赋值给右侧类型。

以下 CheckStringAgainstNumber 条件类型检查 string extends number,换句话说,检查 string 类型是否可赋值给 number 类型。它不可以,因此结果类型是“如果为 false” 的情况:false

// 类型:false
type CheckStringAgainstNumber = string extends number ? true : false;

本章的大部分内容将涉及将其他类型系统特性与条件类型结合使用。随着代码片段变得更复杂,请记住:每个条件类型都是纯粹的布尔逻辑。每个条件类型接受某种类型,并得出两种可能的结果之一。

泛型条件类型

条件类型能够检查其作用域中的任何类型名称,包括条件类型本身的类型参数。这意味着你可以编写可重用的泛型类型,根据其他任何类型创建新的类型。

将前面的 CheckStringAgainstNumber 类型转换为泛型的 CheckAgainstNumber 类型,根据先前的类型是否可赋值给 number,该类型将为 truefalsestring 仍然为 false,而 number0 | 1 则为 true

type CheckAgainstNumber<T> = T extends number ? true : false;

// 类型:false
type CheckString = CheckAgainstNumber<'parakeet'>;

// 类型:true
type CheckString = CheckAgainstNumber<1891>; 

// 类型:true
type CheckString = CheckAgainstNumber<number>;

以下的 CallableSetting 类型更有用一些。它接受一个泛型 T,并检查 T 是否为函数类型。如果 T 是函数类型,则结果类型为 T,例如GetNumbersSetting 中的 T() => number[]。否则,结果类型是一个返回 T 的函数,例如 StringSetting 中的 Tstring,因此结果类型为 () => string

type CallableSetting<T> = T extends () => any
  ? T
  : () => T

// 类型:() => number[]
type GetNumbersSetting = CallableSetting<() => number[]>;

// 类型:() => string
type StringSetting = CallableSetting<string>;

条件类型还能够使用对象成员查找语法访问提供的类型的成员。它们可以在 extends 子句和/或结果类型中使用该信息。

一个常用于 JavaScript 库的模式非常适合使用条件泛型类型,即根据提供给函数的选项对象来改变函数的返回类型。

例如,许多数据库函数或类似函数可能会使用 throwIfNotFound 属性来决定如果未找到值,函数是否抛出错误而不是返回 undefined。以下的 QueryResult 类型通过在选项的 throwIfNotFound 明确为 true 时生成更精确的 string 类型而不是 string | undefined ,来模拟该行为:

interface QueryOptions {
  throwIfNotFound: boolean;
}

type QueryResult<Options extends QueryOptions> = Options["throwIfNotFound"] extends true ? string : string | undefined;

declare function retrieve<Options extends QueryOptions>(key: string,
  options?: Options,
): Promise<QueryResult<Options>>;

// 返回类型:string | undefined
await retrieve("Biruté Galdikas");

// 返回类型:string | undefined
await retrieve("Jane Goodall", { throwIfNotFound: Math.random() > 0.5 });

// 返回类型:string
await retrieve("Dian Fossey", { throwIfNotFound: true });

通过将条件类型与泛型类型参数结合使用,retrieve函数能更准确地告诉类型系统它将如何改变程序的控制流程。

类型分发性

类型分发性是指条件类型在联合类型上的分发行为,这意味着其结果类型将是将该条件类型应用于每个成员(联合类型中的类型)后的并集。换句话说,ConditionalType<T | U>Conditional<T> | Conditional<U> 是相同的。

类型分发性是一个复杂的概念,但对于条件类型在联合类型中的行为非常重要。

考虑下面的 ArrayifyUnlessString 类型,它将类型参数 T 转换为数组,除非 T extends stringHalfArrayified 等同于 string | number[],因为 ArrayifyUnlessString<string | number>ArrayifyUnlessString<string> | ArrayifyUnlessString<number> 是相同的:

type ArrayifyUnlessString<T> = T extends string ? T : T[];

// 类型:string | number[]
type HalfArrayified = ArrayifyUnlessString<string | number>;

如果 TypeScript 的条件类型不能在联合类型上进行分发,那么 HalfArrayified 将是 (string | number)[],因为 string | number不能赋值给 string。换句话说,条件类型将逐个成员应用其逻辑,而不是整个联合类型。

推断类型

访问所提供类型的成员对于存储为类型成员的信息非常有效,但无法捕获函数参数或返回类型等其他信息。条件类型能够通过在其 extends 子句中使用 infer 关键字来访问其条件的任意部分。在 extends 子句中放置 infer 关键字和一个新类型的名称意味着该新类型将在条件类型的 true 分支内可用。

这个 ArrayItems 类型接受一个类型参数 T,并检查 T 是否是某种新 Item 类型的数组。如果是,结果类型为 Item;如果不是,则为 T

type ArrayItems<T> =
  T extends (infer Item)[]
  ? Item
  : T;

// 类型:string
type StringItem = ArrayItems<string>;

// 类型:string[]
type StringArrayItem = ArrayItems<string[]>;

// 类型:string[][]
type String2DItem = ArrayItems<string[][]>;

推断类型也可以用于创建递归条件类型。前面看到的 ArrayItems 类型可以扩展为递归检索任何维度数组的项类型:

type ArrayItemsRecursive<T> = T extends (infer Item)[]
  ? ArrayItemsRecursive<Item>
  : T;

// 类型:string
type StringItem = ArrayItemsRecursive<string>;

// 类型:string
type StringArrayItem = ArrayItemsRecursive<string[]>;

// 类型:string
type String2DItem = ArrayItemsRecursive<string[][]>;

请注意,虽然 ArrayItems<string[][]> 的结果是 string[],但 ArrayItemsRecursive<string[][]> 的结果是 string。泛型类型具有递归性的能力使它们能够持续应用修改操作,例如在这里检索数组的元素类型。

映射的条件类型

映射条件类型是将更改应用于现有类型的每个成员的一种方式。条件类型是将更改应用于单个现有类型的一种方式。结合起来,它们允许对泛型模板类型的每个成员应用条件逻辑。

这个 MakeAllMembersFunctions 类型将类型的每个非函数成员转换为函数:

type MakeAllMembersFunctions<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any
  ? T[K]
  : () => T[K]
};

type MemberFunctions = MakeAllMembersFunctions<{
  alreadyFunction: () => string,
  notYetFunction: number,
}>;
// 类型:
// {
//   alreadyFunction: () => string,
//   notYetFunction: () => number,
// }

映射条件类型是一种方便可以使用某种逻辑检查修改现有类型的所有属性的方式。

Never

在第 4 章“对象”中,我介绍了 never 类型,它是一个底部类型,意味着它没有可能的值并且无法到达。在适当的位置添加 never 类型注解可以告诉 TypeScript 在类型系统中更积极地检测到永远无法到达的代码路径,以及前面示例中的运行时代码。

Never、交叉和联合

另一种描述 never 底部类型的方式是它是一个不存在的类型。这使得 never& 交叉类型和 | 联合类型中具有一些有趣的行为:

  • & 交叉类型中的 never 会将交叉类型简化为 never
  • | 联合类型中的 never 会被忽略。

以下 NeverIntersectionNeverUnion 类型展示了这些行为:

type NeverIntersection = never & string; // Type: never
type NeverUnion = never | string; // Type: string

特别是在联合类型中被忽略的行为使得 never 在条件类型和映射类型中用于过滤值非常有用。

Never 和条件类型

在泛型条件类型中,通常使用 never 来过滤联合类型中的类型。因为 never 在联合类型中被忽略,所以对联合类型进行泛型条件操作的结果只会包含非 never 的类型。

这个 OnlyStrings 泛型条件类型用于过滤非字符串类型,所以 RedOrBlue 类型将从联合类型中过滤掉 0null

type OnlyStrings<T> = T extends string ? T : never;

type RedOrBlue = OnlyStrings<"red" | "blue" | 0 | false>;
// 相当于:"red" | "blue"

never 在为泛型类型创建类型工具时通常与推断条件类型结合使用。使用 infer 进行类型推断必须在条件类型的 true 分支中进行,因此如果 false 分支永远不会被使用,那么 never 是一个适合的类型。

这个 FirstParameter 类型接受一个函数类型 T,检查它是否是一个带有 arg: infer Arg 的函数,并在是的情况下返回 Arg

type FirstParameter<T extends (...args: any[]) => any> = 
  T extends (arg: infer Arg) => any
    ? Arg
    : never;

type GetsString = FirstParameter<(arg0: string) => void>; // 类型:string

在条件类型的错误情况下使用 never 允许 FirstParameter 提取函数第一个参数的类型。

never 和映射类型

以下 OnlyStringProperties 类型将每个 T[K] 成员转换为 K 键(如果该成员是字符串)或 never 键(如果不是):

never 在联合类型中的行为使得它在映射类型中过滤成员非常有用。可以使用以下三个类型系统特性来过滤对象的键:

  • never 在联合类型中被忽略。
  • 映射类型可以映射类型的成员。
  • 条件类型可以根据条件将类型转换为 never

将这三者结合起来,可以创建一个映射类型,将基本类型的每个成员要么转换为原始键 K,要么转换为 never。然后,通过使用 [keyof T] 来获取该类型的成员,就会产生一个包含所有映射类型结果的联合类型,从而过滤掉 never

下面的 OnlyStringProperties 类型将每个 T[K] 成员要么转换为 K 键(如果该成员是字符串),要么转换为 never(如果不是字符串):

type OnlyStringProperties<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

interface AllEventData {
  participants: string[];
  location: string;
  name: string;
  year: number;
}

type OnlyStringEventData = OnlyStringProperties<AllEventData>;
//  相当于:"location" | "name"

读取 OnlyStringProperties 类型的另一种方法是过滤掉所有非字符串属性(将它们切换到 never),然后返回所有剩余的键([keyof T])。

模板字面量类型

我们已经详细介绍了条件类型和映射类型。现在让我们转换一下,将注意力放在字符串类型上。到目前为止,我已经提到了两种用于类型化字符串值的策略:

  • 基本类型 string:适用于值可以是世界上的任何字符串的情况。
  • 字面量类型,例如 """abc":适用于值只能是特定的类型(或其联合)的情况。

然而,有时候你可能想要指示一个字符串匹配某种模式:部分字符串是已知的,但另一部分是未知的。这时就需要用到模板字面量类型,它是 TypeScript 的一种语法,用于表示字符串类型符合某种模式。它们的形式类似于模板字面量字符串,因此被称为模板字面量类型,但其中插入的是基本类型或基本类型的联合。

下面的模板字面量类型表示字符串必须以 "Hello" 开头,但可以以任何字符串(string)结尾。以 "Hello" 开头的名称,如 "Hello, world!" 是匹配的,但不匹配 "World! Hello!""hi"

type Greeting = `Hello${string}`;

let matches: Greeting = "Hello, world!"; // Ok

let outOfOrder: Greeting = "World! Hello!";
// ~~~~~~~~~~
// 错误:不能将类型“"World! Hello!"”分配给类型“`Hello${string}`”。

let missingAltogether: Greeting = "hi";
// ~~~~~~~~~~~~~~~~~
// 错误:不能将类型“"hi"”分配给类型“`Hello${string}`”。

字符串字面量类型以及它们的联合可以在类型插值中使用,而不是通用的 string 基本类型,从而将模板字面量类型限制为更窄的字符串模式。模板字面量类型在描述必须与一组允许的字符串匹配的字符串时非常有用。

在下面的示例中,BrightnessAndColor 仅匹配以 Brightness 开头,以 Color 结尾,并且在中间有一个 - 连字符的字符串:

type Brightness = "dark" | "light";
type Color = "blue" | "red";

type BrightnessAndColor = `${Brightness}-${Color}`;
// 相当于:"dark-red" | "light-red" | "dark-blue" | "light-blue"

let colorOk: BrightnessAndColor = "dark-blue"; // Ok

let colorWrongStart: BrightnessAndColor = "medium-blue";
// ~~~~~~~~~~~~~~~
// 错误:不能将类型“"medium-blue"”分配给类型
// “"dark-blue" | "dark-red" | "light-blue" | "light-red"”。

let colorWrongEnd: BrightnessAndColor = "light-green";
// ~~~~~~~~~~~~~
// 错误:Type '"light-green"' is not assignable to type
// “"dark-blue" | "dark-red" | "light-blue" | "light-red"”。

如果没有模板字面量类型,我们将不得不费力地编写 BrightnessColor 的所有四种组合。如果我们为它们中的任何一个添加更多的字符串字面量,这将变得非常麻烦!

TypeScript 允许模板字面量类型包含任何基本类型(除了 symbol)或它们的联合:stringnumberbigintbooleannullundefined

下面的 ExtolNumber 类型允许以 "much " 开头,包含一个看起来像数字的字符串,并以 "wow" 结尾的任何字符串:

type ExtolNumber = `much ${number} wow`;
function extol(extolee: ExtolNumber) { /* ... */ } extol('much 0 wow'); // Ok
extol('much -7 wow'); // Ok
extol('much 9.001 wow'); // Ok

extol('much false wow');
//    ~~~~~~~~~~~~~~~~
// 错误:类型“"much false wow"”的参数不能
// 赋给类型“`much ${number} wow`”的参数。

内置字符串操作类型

为了辅助处理字符串类型,TypeScript 提供了一小组内置(即内置在 TypeScript 中)的通用类型,可以对字符串进行一些操作。截至 TypeScript 4.7.2 版本,共有四种类型:

  • Uppercase:将字符串字面量类型转换为大写。
  • Lowercase:将字符串字面量类型转换为小写。
  • Capitalize:将字符串字面量类型的第一个字符转换为大写。
  • Uncapitalize:将字符串字面量类型的第一个字符转换为小写。

每个类型都可以作为一个泛型类型,接受一个字符串作为参数。例如,使用 Capitalize 将字符串的首字母大写:

type FormalGreeting = Capitalize<"hello.">; // Type: "Hello."

这些内置的字符串操作类型对于操作对象类型的属性键非常有用。

模板字面量键

模板字面量类型是基本类型 string 和字符串字面量之间的一种中间状态,这意味着它们仍然是字符串。你可以在任何可以使用字符串字面量的地方使用它们。

例如,你可以将它们用作映射类型中的索引签名。下面的 ExistenceChecks 类型根据 DataKey 中的每个字符串,映射生成 check${Capitalize<DataKey>} 的键:

type DataKey = "location" | "name" | "year";

type ExistenceChecks = {
  [K in `check${Capitalize<DataKey>}`]: () => boolean;
};
// 相当于:
// {
//    checkLocation: () => boolean;
//    checkName: () => boolean;
//    checkYear: () => boolean;
// }

function checkExistence(checks: ExistenceChecks) {
  checks.checkLocation(); // Type: boolean checks.checkName(); // Type: boolean

  checks.checkWrong();
  //    ~~~~~~~~~~
  // 错误:类型“ExistenceChecks”上不存在属性“checkWrong”。
}

重映射类型键

TypeScript 允许你使用模板字面量类型为映射类型的成员创建新的键。在映射类型的索引签名后面使用 as 关键字,后跟模板字面量类型,可以将结果类型的键更改为与模板字面量类型匹配。通过这样做,映射类型可以为每个映射属性具有不同的键,同时仍然引用原始值。

在下面的例子中,DataEntryGetters 是一个映射类型,其键是 getLocationgetNamegetYear。每个键都映射到一个带有模板字面量类型的新键。每个映射值都是一个函数,其返回类型是使用原始的 K 键作为类型参数的 DataEntry

interface DataEntry<T> { 
  key: T;
  value: string;
}

type DataKey = "location" | "name" | "year";

type DataEntryGetters = {
  [K in DataKey as `get${Capitalize<K>}`]: () => DataEntry<K>;
};
//  相当于:
//  {
//    getLocation: () => DataEntry<"location">;
//    etName: () => DataEntry<"name">;
//    getYear: () => DataEntry<"year">;
//  }

键重映射可以与其他类型操作结合使用,以创建基于现有类型形状的映射类型。其中一种有趣的组合是对现有对象使用 keyof typeof,以创建基于该对象类型的映射类型。

下面的 ConfigGetter 类型基于 config 类型,但每个字段都是返回原始配置的函数,并且键已经从原始键进行了修改:

const config = {
  location: "unknown", name: "anonymous", year: 0,
};

type LazyValues = {
  [K in keyof typeof config as `${K}Lazy`]: () => Promise<typeof config[K]>;
};
// 相当于:
// {
//     location: Promise<string>;
//     name: Promise<string>;
//     year: Promise<number>;
// }

async function withLazyValues(configGetter: LazyValues) {
  await configGetter.locationLazy; // Resultant type: string

  await configGetter.missingLazy();
  //    ~~~~~~~~~~~
  // 错误:类型“LazyValues”上不存在属性“missingLazy”。
};

请注意,在 JavaScript 中,对象键可以是 stringSymbol 类型,而 Symbol 键无法用作模板字面量类型,因为它们不是基本类型。如果你尝试在泛型类型中使用重映射的模板字面量类型键,TypeScript 会抛出一个错误,指出 symbol 不能用作模板字面量类型:

type TurnIntoGettersDirect<T> = {
  [K in keyof T as `get${K}`]: () => T[K]
  //    ~
  // 不能将类型“K”分配给类型“string | number | bigint | boolean | null | undefined”。
  // 不能将类型“keyof T”分配给类型“string | number | bigint | boolean | null | undefined”。
  //  不能将类型“string | number | symbol”分配给类型“string | number | bigint | boolean | null | undefined”。
  //    不能将类型“symbol”分配给类型“string | number | bigint | boolean | null | undefined”。
};

为了绕过此限制,你可以使用 string & 交叉类型来强制只使用可为字符串的类型。因为 string & symbol 的结果是 never,整个模板字符串将会被减少为 never,TypeScript会忽略它:

const someSymbol = Symbol("");

interface HasStringAndSymbol {
  StringKey: string;[someSymbol]: number;
}

type TurnIntoGetters<T> = {
  [K in keyof T as `get${string & K}`]: () => T[K]
};

type GettersJustString = TurnIntoGetters<HasStringAndSymbol>;
// 相当于:
// {
//    getStringKey: () => string;
// }

TypeScript 从联合中过滤掉 never 类型的行为再次证明了其有用性!

类型操作和复杂性

调试比起一开始编写代码来说要难两倍。因此,如果你编写的代码过于聪明,那么根据定义,你自己的智商是不够以便调试它的。
——Brian Kernighan

本章介绍的类型操作是当今任何编程语言中最强大、最前沿的类型系统功能之一。大多数开发人员还不熟悉这些功能,无法调试复杂的使用情况中的错误。像我在第 12 章“使用 IDE 功能”中介绍的行业标准开发工具通常不适用于可视化相互使用的多层类型操作。

如果你确实需要使用类型操作,请尽量减少使用,为了任何阅读你代码的开发人员(包括未来的你)的利益,请使用可读性强的名称帮助读者在阅读代码时理解它。对于你认为未来的读者可能会遇到困难的部分,请留下描述性的评论。

总结

在本章中,你通过对类型进行操作,揭示了 TypeScript 的真正威力:

  • 使用映射类型将现有类型转换为新类型
  • 使用条件类型在类型操作中引入逻辑
  • 了解 never 类型在交叉类型、联合类型、条件类型和映射类型中的相互作用
  • 使用模板字面量类型表示字符串类型的模式
  • 结合模板字面量类型和映射类型来修改类型的键
现在你已经读完了这一章,你最好练习一下学到的东西 https://learningtypescript.com/type-operations

当你迷失在类型系统中时,你会使用什么?
映射类型!

1

评论 (0)

取消