类型派生

类型派生

探索 TypeScript 的高级类型派生技术:keyoftypeof、索引访问类型以及用于枚举的 as const

编写可维护代码最常见的建议之一是"保持代码 DRY",或者更明确地说,"不要重复自己"。

在 JavaScript 中实现这一点的一种方法是将重复的代码提取出来,并将其封装在函数或变量中。这些变量和函数可以被重用、组合,以不同的方式创建新的功能。

在 TypeScript 中,我们可以将同样的原则应用于类型。

在本节中,我们将探讨从其他类型派生类型。这使我们能够减少代码中的重复,并为我们的类型创建单一的真实来源。

这使我们能够在一个类型中进行更改,并将这些更改传播到整个应用程序,而无需手动更新每个实例。

我们甚至将研究如何从派生类型,以便我们的类型始终代表应用程序的运行时行为。

派生类型

派生类型是依赖于或继承自另一种类型结构的类型。我们可以使用到目前为止已经使用过的一些工具来创建派生类型。

我们可以使用 interface extends 使一个接口继承另一个接口:

typescript 复制代码
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}

interface AlbumDetails extends Album {
  genre: string;
}

AlbumDetails 继承了 Album 的所有属性。这意味着对 Album 的任何更改都将传递给 AlbumDetailsAlbumDetails 是从 Album 派生出来的。

另一个例子是联合类型。

typescript 复制代码
type Triangle = {
  type: "triangle";
  sideLength: number;
};

type Rectangle = {
  type: "rectangle";
  width: number;
  height: number;
};

type Shape = Triangle | Rectangle;

派生类型代表一种关系。这种关系是单向的。Shape 不能反过来修改 TriangleRectangle。但是对 TriangleRectangle 的任何更改都会影响到 Shape

当设计良好时,派生类型可以极大地提高生产力。我们可以在一个地方进行更改,并让它们在整个应用程序中传播。这是保持代码 DRY 并充分利用 TypeScript 类型系统的强大方法。

这也有权衡。我们可以将派生视为一种耦合。如果我们更改了其他类型依赖的类型,我们需要意识到该更改的影响。我们将在本章末尾更详细地讨论派生与解耦。

但现在,让我们看看 TypeScript 为派生类型提供的一些工具。

keyof 操作符

keyof 操作符允许你从对象类型中提取键,并形成一个联合类型。

从我们熟悉的 Album 类型开始:

typescript 复制代码
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}

我们可以使用 keyof Album,得到一个包含 "title""artist""releaseYear" 键的联合类型:

typescript 复制代码
type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear"

由于 keyof 会跟踪源类型的键,因此对该类型所做的任何更改都将自动反映在 AlbumKeys 类型中。

typescript 复制代码
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
  genre: string; // 添加了 'genre'
}

type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear" | "genre"

然后,AlbumKeys 类型可以用于帮助确保用于访问 Album 中值的键是有效的,如此函数所示:

typescript 复制代码
function getAlbumDetails(album: Album, key: AlbumKeys) {
  return album[key];
}

如果传递给 getAlbumDetails 的键不是 Album 的有效键,TypeScript 将显示错误:

typescript 复制代码
getAlbumDetails(album, "producer");
// Argument of type '"producer"' is not assignable to parameter of type 'keyof Album'.2345
// 类型 '"producer"' 的参数不能赋给类型 'keyof Album' 的参数。2345

keyof 是从现有类型创建新类型时的重要构建块。稍后我们将看到如何将其与 as const 一起使用来构建我们自己的类型安全的枚举。

typeof 操作符

typeof 操作符允许你从一个值中提取类型。

假设我们有一个 albumSales 对象,其中包含一些专辑标题键和一些销售统计数据:

typescript 复制代码
const albumSales = {
  "Kind of Blue": 5000000,
  "A Love Supreme": 1000000,
  "Mingus Ah Um": 3000000,
};

我们可以使用 typeof 来提取 albumSales 的类型,这将把它转换成一个类型,其中原始键为字符串,它们推断出的类型为值:

typescript 复制代码
type AlbumSalesType = typeof albumSales;
/*
type AlbumSalesType = {
    "Kind of Blue": number;
    "A Love Supreme": number;
    "Mingus Ah Um": number;
}
*/

现在我们有了 AlbumSalesType 类型,我们可以从中创建另一个 派生类型。例如,我们可以使用 keyofalbumSales 对象中提取键:

typescript 复制代码
type AlbumTitles = keyof AlbumSalesType; // "Kind of Blue" | "A Love Supreme" | "Mingus Ah Um"

一种常见的模式是结合 keyoftypeof 从现有对象类型的键和值创建新类型:

typescript 复制代码
type AlbumTitles = keyof typeof albumSales;

我们可以在一个函数中使用它来确保 title 参数是 albumSales 的有效键,例如查找特定专辑的销售额:

typescript 复制代码
function getSales(title: AlbumTitles) {
  return albumSales[title];
}

值得注意的是,这个 typeof 与运行时使用的 typeof 操作符不同。TypeScript 可以根据它是在类型上下文还是值上下文中使用来区分它们:

typescript 复制代码
// 运行时 typeof
const albumSalesType = typeof albumSales; // "object"

// 类型 typeof
type AlbumSalesType = typeof albumSales;
/*
type AlbumSalesType = {
    "Kind of Blue": number;
    "A Love Supreme": number;
    "Mingus Ah Um": number;
}
*/

当你需要基于运行时值(包括对象、函数、类等)提取类型时,请使用 typeof 关键字。它是从值派生类型的强大工具,也是我们稍后将探讨的其他模式的关键构建块。

你不能从值创建运行时类型

我们已经看到 typeof 可以从运行时值创建类型,但重要的是要注意,没有办法从类型创建值。

换句话说,没有 valueof 操作符:

typescript 复制代码
type Album = {
  title: string;
  artist: string;
  releaseYear: number;
};

// const album = valueof Album; // 这行不通!

TypeScript 的类型在运行时会消失,因此没有内置的方法可以从类型创建值。换句话说,你可以从"值世界"进入"类型世界",但不能反过来。

索引访问类型

TypeScript 中的索引访问类型允许你访问另一个类型的属性。这类似于在运行时访问对象中属性的值,但它在类型级别上操作。

例如,我们可以使用索引访问类型从 AlbumDetails 中提取 title 属性的类型:

typescript 复制代码
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}

如果我们尝试使用点表示法从 Album 类型访问 title 属性,TypeScript 将抛出错误:

typescript 复制代码
// type AlbumTitle = Album.title;
// Cannot access 'Album.title' because 'Album' is a type, but not a namespace. Did you mean to retrieve the type of the property 'title' in 'Album' with 'Album["title"]'?2713
// 无法访问 'Album.title' 因为 'Album' 是一个类型,但不是一个命名空间。你是想用 'Album["title"]' 来获取 'Album' 中 'title' 属性的类型吗?2713

在这种情况下,错误消息有一个有用的建议:使用 Album["title"] 来访问 Album 类型中 title 属性的类型:

typescript 复制代码
type AlbumTitle = Album["title"];
// type AlbumTitle = string

使用这种索引访问语法,AlbumTitle 类型等同于 string,因为这是 Album 接口中 title 属性的类型。

同样的方法也可以用于从元组中提取类型,其中索引用于访问元组中特定元素的类型:

typescript 复制代码
type AlbumTuple = [string, string, number];

type AlbumTitle = AlbumTuple[0]; // string

再次强调,AlbumTitle 将是 string 类型,因为这是 AlbumTuple 中第一个元素的类型。

链接多个索引访问类型

索引访问类型可以链接在一起以访问嵌套属性。这在处理具有嵌套结构的复杂类型时非常有用。

例如,我们可以使用索引访问类型从 Album 类型中的 artist 属性中提取 name 属性的类型:

typescript 复制代码
interface Album {
  title: string;
  artist: {
    name: string;
  };
}

type ArtistName = Album["artist"]["name"]; // string

在这种情况下,ArtistName 类型将等同于 string,因为这是 artist 对象中 name 属性的类型。

将联合类型传递给索引访问类型

如果你想从一个类型中访问多个属性,你可能会想创建一个包含多个索引访问的联合类型:

typescript 复制代码
type Album = {
  title: string;
  isSingle: boolean;
  releaseYear: number;
};

type AlbumPropertyTypes =
  | Album["title"]
  | Album["isSingle"]
  | Album["releaseYear"]; // string | boolean | number

这会起作用,但你可以做得更好------你可以直接将联合类型传递给索引访问类型:

typescript 复制代码
type AlbumPropertyTypes = Album["title" | "isSingle" | "releaseYear"];
// type AlbumPropertyTypes = string | number | boolean

这是实现相同结果的更简洁的方法。

使用 keyof 获取对象的值

事实上,你可能已经注意到我们这里还有另一个减少重复的机会。我们可以使用 keyofAlbum 类型中提取键,并将它们用作联合类型:

typescript 复制代码
type AlbumPropertyTypes = Album[keyof Album];
// type AlbumPropertyTypes = string | number | boolean

当你想从对象类型中提取所有值时,这是一个很好的模式。keyof Obj 会给你 Obj 中所有 的联合,而 Obj[keyof Obj] 会给你 Obj 中所有的联合。

使用 as const 实现 JavaScript 风格的枚举

在我们关于 TypeScript 独有特性的章节中,我们研究了 enum 关键字。我们看到 enum 是创建一组命名常量的强大方法,但它有一些缺点。

我们现在拥有所有可用的工具,可以看到一种在 TypeScript 中创建类似枚举结构的替代方法。

首先,让我们使用我们在关于可变性的章节中看到的 as const 断言。这将强制对象被视为只读,并为其属性推断字面量类型:

typescript 复制代码
const albumTypes = {
  CD: "cd",
  VINYL: "vinyl",
  DIGITAL: "digital",
} as const;

我们现在可以使用 keyoftypeofalbumTypes 派生 我们需要的类型。例如,我们可以使用 keyof 获取键:

typescript 复制代码
type UppercaseAlbumType = keyof typeof albumTypes; // "CD" | "VINYL" | "DIGITAL"

我们也可以使用 Obj[keyof Obj] 获取值:

typescript 复制代码
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes]; // "cd" | "vinyl" | "digital"

我们现在可以使用我们的 AlbumType 类型来确保函数只接受来自 albumTypes 的值之一:

typescript 复制代码
function getAlbumType(type: AlbumType) {
  // ...
}

这种方法有时被称为"POJO",即"Plain Old JavaScript Object"(纯粹的 JavaScript 对象)。虽然设置类型需要一些 TypeScript 的技巧,但结果易于理解和使用。

现在让我们将其与 enum 方法进行比较。

枚举要求你传递枚举值

我们的 getAlbumType 函数的行为与接受 enum 的函数不同。因为 AlbumType 只是字符串的联合,我们可以将原始字符串传递给 getAlbumType。但是如果我们传递了不正确的字符串,TypeScript 将显示错误:

typescript 复制代码
getAlbumType(albumTypes.CD); // 没有错误
getAlbumType("vinyl");        // 没有错误
getAlbumType("cassette");
// Argument of type '"cassette"' is not assignable to parameter of type 'AlbumType'.2345
// 类型 '"cassette"' 的参数不能赋给类型 'AlbumType' 的参数。2345

这是一个权衡。使用 enum,你必须传递枚举值,这更明确。使用我们的 as const 方法,你可以传递原始字符串。这可能会使重构稍微困难一些。

枚举必须导入

enum 的另一个缺点是它们必须导入到你正在使用的模块中才能使用它们:

typescript 复制代码
// import { AlbumType } from "./enums";
// getAlbumType(AlbumType.CD);

使用我们的 as const 方法,我们不需要导入任何东西。我们可以传递原始字符串:

typescript 复制代码
getAlbumType("cd");

枚举的拥护者会认为导入枚举是一件好事,因为它清楚地表明了枚举的来源,并使重构更容易。

枚举是名义化的

enum 和我们的 as const 方法之间最大的区别之一是 enum名义化的 (nominal),而我们的 as const 方法是结构化的 (structural)。

这意味着使用 enum,类型是基于枚举的名称。这意味着来自不同枚举但具有相同值的枚举是不兼容的:

typescript 复制代码
enum AlbumType {
  CD = "cd",
  VINYL = "vinyl",
  DIGITAL = "digital",
}

enum MediaType {
  CD = "cd",
  VINYL = "vinyl",
  DIGITAL = "digital",
}

// function getAlbumType(type: AlbumType) { /* ... */ }
getAlbumType(AlbumType.CD);

// getAlbumType(MediaType.CD);
// Argument of type 'MediaType.CD' is not assignable to parameter of type 'AlbumType'.2345
// 类型 'MediaType.CD' 的参数不能赋给类型 'AlbumType' 的参数。2345

如果你习惯了其他语言中的枚举,这可能是你所期望的。但对于习惯了 JavaScript 的开发人员来说,这可能会令人惊讶。

使用 POJO,值的来源无关紧要。如果两个 POJO 具有相同的值,它们是兼容的:

typescript 复制代码
const albumTypes = {
  CD: "cd",
  VINYL: "vinyl",
  DIGITAL: "digital",
} as const;

const mediaTypes = {
  CD: "cd",
  VINYL: "vinyl",
  DIGITAL: "digital",
} as const;

// function getAlbumType(type: (typeof albumTypes)[keyof typeof albumTypes]) { /* ... */ }
getAlbumType(albumTypes.CD); // 没有错误
getAlbumType(mediaTypes.CD); // 没有错误

这是一个权衡。名义化类型可以更明确并帮助捕获错误,但它也可能更具限制性且更难使用。

你应该使用哪种方法?

enum 方法更明确,可以帮助你重构代码。对于来自其他语言的开发人员来说,它也更熟悉。

as const 方法更灵活,更容易使用。对于 JavaScript 开发人员来说,它也更熟悉。

总的来说,如果你正在与习惯使用 enum 的团队合作,你应该使用 enum。但如果我今天开始一个项目,我会使用 as const 而不是枚举。

练习

练习 1:减少键的重复

这里我们有一个名为 FormValues 的接口:

typescript 复制代码
interface FormValues {
  name: string;
  email: string;
  password: string;
}

这个 inputs 变量被类型化为一个 Record,它指定键为 nameemailpassword 之一,值为一个对象,该对象具有 initialValuelabel 属性,两者都是字符串:

typescript 复制代码
const inputs: Record<
  "name" | "email" | "password", // 修改我!
  {
    initialValue: string;
    label: string;
  }
> = {
  name: {
    initialValue: "",
    label: "Name",
  },
  email: {
    initialValue: "",
    label: "Email",
  },
  password: {
    initialValue: "",
    label: "Password",
  },
};

注意这里有很多重复。FormValues 接口和 inputs Record 都包含 nameemailpassword

你的任务是修改 inputs Record,使其键从 FormValues 接口派生。

练习 2:从值派生类型

在这里,我们有一个名为 configurations 的对象,它包含一组用于 developmentproductionstaging 的部署环境。

每个环境都有其自己的 url 和超时设置:

typescript 复制代码
const configurations = {
  development: {
    apiBaseUrl: "http://localhost:8080",
    timeout: 5000,
  },
  production: {
    apiBaseUrl: "https://api.example.com",
    timeout: 10000,
  },
  staging: {
    apiBaseUrl: "https://staging.example.com",
    timeout: 8000,
  },
};

一个 Environment 类型已声明如下:

typescript 复制代码
type Environment = "development" | "production" | "staging";

我们希望在整个应用程序中使用 Environment 类型。但是,configurations 对象应该用作真实来源。

你的任务是更新 Environment 类型,使其从 configurations 对象派生。

练习 3:访问特定值

这里我们有一个 programModeEnumMap 对象,它使不同的分组保持同步。还有一个 ProgramModeMap 类型,它使用 typeof 来表示整个枚举映射:

typescript 复制代码
export const programModeEnumMap = {
  GROUP: "group",
  ANNOUNCEMENT: "announcement",
  ONE_ON_ONE: "1on1",
  SELF_DIRECTED: "selfDirected",
  PLANNED_ONE_ON_ONE: "planned1on1",
  PLANNED_SELF_DIRECTED: "plannedSelfDirected",
} as const;

type ProgramModeMap = typeof programModeEnumMap;

目标是拥有一个 Group 类型,它始终与 programModeEnumMapgroup 值同步。目前它被类型化为 unknown

typescript 复制代码
// import { Equal, Expect } from "@total-typescript/helpers"; // 假设这个已导入

type Group = unknown;

// type test = Expect<Equal<Group, "group">>;
// Type 'false' does not satisfy the constraint 'true'.2344
// 类型 'false' 不满足约束 'true'。2344

你的任务是找到正确的方法来类型化 Group,以便测试按预期通过。

练习 4:使用索引访问类型的联合类型

本练习从与前一个练习相同的 programModeEnumMapProgramModeMap 开始:

typescript 复制代码
export const programModeEnumMap = {
  GROUP: "group",
  ANNOUNCEMENT: "announcement",
  ONE_ON_ONE: "1on1",
  SELF_DIRECTED: "selfDirected",
  PLANNED_ONE_ON_ONE: "planned1on1",
  PLANNED_SELF_DIRECTED: "plannedSelfDirected",
} as const;

type ProgramModeMap = typeof programModeEnumMap;

type PlannedPrograms = unknown;

// import { Equal, Expect } from "@total-typescript/helpers"; // 假设这个已导入
// type test = Expect<
//   Equal<PlannedPrograms, "planned1on1" | "plannedSelfDirected">
// >;
// Type 'false' does not satisfy the constraint 'true'.2344
// 类型 'false' 不满足约束 'true'。2344

这一次,你的挑战是更新 PlannedPrograms 类型,以使用索引访问类型来提取 ProgramModeMap 值中包含 "planned" 的联合。

练习 5:提取所有值的联合

我们又回到了 programModeEnumMapProgramModeMap 类型:

typescript 复制代码
export const programModeEnumMap = {
  GROUP: "group",
  ANNOUNCEMENT: "announcement",
  ONE_ON_ONE: "1on1",
  SELF_DIRECTED: "selfDirected",
  PLANNED_ONE_ON_ONE: "planned1on1",
  PLANNED_SELF_DIRECTED: "plannedSelfDirected",
} as const;

type ProgramModeMap = typeof programModeEnumMap;

这次我们感兴趣的是从 programModeEnumMap 对象中提取所有值:

typescript 复制代码
// import { Equal, Expect } from "@total-typescript/helpers"; // 假设这个已导入

// ---cut---

type AllPrograms = unknown;

// type test = Expect<
//   Equal<
//     AllPrograms,
//     | "group"
//     | "announcement"
//     | "1on1"
//     | "selfDirected"
//     | "planned1on1"
//     | "plannedSelfDirected"
//   >
// >;

利用你到目前为止所学的知识,你的任务是更新 AllPrograms 类型,以使用索引访问类型从 programModeEnumMap 对象创建所有值的联合。

练习 6:从 as const 数组创建联合类型

这是一个用 as const 包装的 programModes 数组:

typescript 复制代码
export const programModes = [
  "group",
  "announcement",
  "1on1",
  "selfDirected",
  "planned1on1",
  "plannedSelfDirected",
] as const;

已经编写了一个测试来检查 AllPrograms 类型是否是 programModes 数组中所有值的联合:

typescript 复制代码
// import { Equal, Expect } from "@total-typescript/helpers"; // 假设这个已导入

type AllPrograms = unknown;

// ---cut---

// type test = Expect<
//   Equal<
//     AllPrograms,
//     | "group"
//     | "announcement"
//     | "1on1"
//     | "selfDirected"
//     | "planned1on1"
//     | "plannedSelfDirected"
//   >
// >;

你的任务是确定如何创建 AllPrograms 类型,以便测试按预期通过。

注意,仅仅使用 keyoftypeof,采用与前一个练习解决方案类似的方法,并不能完全解决这个问题!这很难找到------但作为一个提示:你可以将原始类型传递给索引访问类型。

解决方案 1:减少键的重复

解决方案是使用 keyofFormValues 接口中提取键,并将它们用作 inputs Record 的键:

typescript 复制代码
// interface FormValues { name: string; email: string; password: string; } // 已定义
const inputs: Record<
  keyof FormValues, // "name" | "email" | "password"
  {
    initialValue: string;
    label: string;
  }
> = {
  name: { initialValue: "", label: "Name" },
  email: { initialValue: "", label: "Email" },
  password: { initialValue: "", label: "Password" },
};

现在,如果 FormValues 接口发生更改,inputs Record 将自动更新以反映这些更改。inputs 是从 FormValues 派生的。

解决方案 2:从值派生类型

解决方案是使用 typeof 关键字与 keyof 结合来创建 Environment 类型。

你可以将它们组合在一行中使用:

typescript 复制代码
// const configurations = { /* ... */ }; // 已定义
type Environment = keyof typeof configurations;

或者你可以首先从 configurations 对象创建一个类型,然后更新 Environment 以使用 keyof 来提取键的名称:

typescript 复制代码
// const configurations = { /* ... */ }; // 已定义
type Configurations = typeof configurations;
/*
type Configurations = {
    development: {
        apiBaseUrl: string;
        timeout: number;
    };
    production: {
        apiBaseUrl: string;
        timeout: number;
    };
    staging: {
        apiBaseUrl: string;
        timeout: number;
    };
}
*/
type Environment = keyof Configurations;
// type Environment = "development" | "production" | "staging"

解决方案 3:访问特定值

使用索引访问类型,我们可以从 ProgramModeMap 类型访问 GROUP 属性:

typescript 复制代码
// export const programModeEnumMap = { /* ... */ } as const; // 已定义
// type ProgramModeMap = typeof programModeEnumMap; // 已定义

type Group = ProgramModeMap["GROUP"];
// type Group = "group"

通过此更改,Group 类型将与 programModeEnumMapgroup 值同步。这意味着我们的测试将按预期通过。

解决方案 4:使用索引访问类型的联合类型

为了创建 PlannedPrograms 类型,我们可以使用索引访问类型来提取 ProgramModeMap 值中包含 "planned" 的联合:

typescript 复制代码
// export const programModeEnumMap = { /* ... */ } as const; // 已定义
// type ProgramModeMap = typeof programModeEnumMap; // 已定义

type Key = "PLANNED_ONE_ON_ONE" | "PLANNED_SELF_DIRECTED";
type PlannedPrograms = ProgramModeMap[Key]; // "planned1on1" | "plannedSelfDirected"

通过此更改,PlannedPrograms 类型将是 planned1on1plannedSelfDirected 的联合,这意味着我们的测试将按预期通过。

解决方案 5:提取所有值的联合

同时使用 keyoftypeof 是解决此问题的方法。

最简洁的解决方案如下所示:

typescript 复制代码
// export const programModeEnumMap = { /* ... */ } as const; // 已定义
type AllPrograms = (typeof programModeEnumMap)[keyof typeof programModeEnumMap];

使用中间类型,你可以首先使用 typeof programModeEnumMapprogramModeEnumMap 对象创建一个类型,然后使用 keyof 提取键:

typescript 复制代码
// export const programModeEnumMap = { /* ... */ } as const; // 已定义
type ProgramModeMap = typeof programModeEnumMap;
type AllPrograms = ProgramModeMap[keyof ProgramModeMap];
// type AllPrograms = "group" | "announcement" | "1on1" | "selfDirected" | "planned1on1" | "plannedSelfDirected"

两种解决方案都会产生 programModeEnumMap 对象中所有值的联合,这意味着我们的测试将按预期通过。

解决方案 6:从 as const 数组创建联合类型

当将 typeofkeyof 与索引访问类型一起使用时,我们可以提取所有值,但我们也会得到一些意外的值,如 6 和一个 IterableIterator 函数:

typescript 复制代码
// export const programModes = [ /* ... */ ] as const; // 已定义
// type AllPrograms = (typeof programModes)[keyof typeof programModes];
/*
type AllPrograms = "group" | "announcement" | "1on1" | "selfDirected" | "planned1on1" | "plannedSelfDirected" | 6 | (() => IterableIterator<"group" | "announcement" | "1on1" | "selfDirected" | "planned1on1" | "plannedSelfDirected">) | ... 23 more ... | ((index: number) => "group" | ... 5 more ... | undefined)
*/

正在提取的额外内容导致测试失败,因为它只期望原始值而不是数字和函数。

回想一下,我们可以使用 programModes[0] 访问第一个元素,使用 programModes[1] 访问第二个元素,依此类推。这意味着我们可以使用所有可能索引值的联合来从 programModes 数组中提取值:

typescript 复制代码
// export const programModes = [ /* ... */ ] as const; // 已定义
type AllPrograms = (typeof programModes)[0 | 1 | 2 | 3 | 4 | 5];

这个解决方案使测试通过,但它不能很好地扩展。如果 programModes 数组发生更改,我们需要手动更新 AllPrograms 类型。

相反,我们可以使用 number 类型作为索引访问类型的参数来表示所有可能的索引值:

typescript 复制代码
// export const programModes = [ /* ... */ ] as const; // 已定义
type AllPrograms = (typeof programModes)[number];

现在可以将新项目添加到 programModes 数组,而无需手动更新 AllPrograms 类型。这个解决方案使测试按预期通过,并且是在你自己的项目中应用的一个很好的模式。

从函数派生类型

到目前为止,我们只研究了从对象和数组派生类型。但是从函数派生类型可以帮助解决 TypeScript 中的一些常见问题。

Parameters

Parameters 工具类型从给定的函数类型中提取参数,并将它们作为元组返回。

例如,这个 sellAlbum 函数接收一个 Album、一个 price 和一个 quantity,然后返回一个表示总价的数字:

typescript 复制代码
// interface Album { title: string; artist: string; releaseYear: number; } // 假设已定义
function sellAlbum(album: Album, price: number, quantity: number) {
  return price * quantity;
}

使用 Parameters 工具类型,我们可以从 sellAlbum 函数中提取参数并将它们分配给一个新类型:

typescript 复制代码
type SellAlbumParams = Parameters<typeof sellAlbum>;
// type SellAlbumParams = [album: Album, price: number, quantity: number]

请注意,我们需要使用 typeofsellAlbum 函数创建一个类型。直接将 sellAlbum 传递给 Parameters 本身是行不通的,因为 sellAlbum 是一个值而不是一个类型:

typescript 复制代码
// type SellAlbumParams = Parameters<sellAlbum>;
// 'sellAlbum' refers to a value, but is being used as a type here. Did you mean 'typeof sellAlbum'?2749
// 'sellAlbum' 指向一个值,但在这里被用作类型。你是想用 'typeof sellAlbum' 吗?2749

这个 SellAlbumParams 类型是一个元组类型,它包含来自 sellAlbum 函数的 Albumpricequantity 参数。

如果我们需要从 SellAlbumParams 类型访问特定参数,我们可以使用索引访问类型:

typescript 复制代码
type Price = SellAlbumParams[1]; // number

ReturnType

ReturnType 工具类型从给定的函数中提取返回类型:

typescript 复制代码
type SellAlbumReturn = ReturnType<typeof sellAlbum>;
// type SellAlbumReturn = number

在这种情况下,SellAlbumReturn 类型是一个数字,它是从 sellAlbum 函数派生的。

Awaited

在本书的前面部分,我们在处理异步代码时使用了 Promise 类型。

Awaited 工具类型用于解包 Promise 类型并提供已解析值的类型。可以把它看作是类似于使用 await.then() 方法的快捷方式。

这对于派生 async 函数的返回类型特别有用。

要使用它,你需要将一个 Promise 类型传递给 Awaited,它将返回已解析值的类型:

typescript 复制代码
// interface Album { title: string; artist: string; releaseYear: number; } // 假设已定义
type AlbumPromise = Promise<Album>;
type AlbumResolved = Awaited<AlbumPromise>; // Album

为什么从函数派生类型?

能够从函数派生类型一开始可能看起来不是很有用。毕竟,如果我们控制函数,那么我们就可以自己编写类型,并根据需要重用它们:

typescript 复制代码
type Album = {
  title: string;
  artist: string;
  releaseYear: number;
};

const sellAlbum = (album: Album, price: number, quantity: number) => {
  return price * quantity;
};

没有理由在 sellAlbum 上使用 ParametersReturnType,因为我们自己定义了 Album 类型和返回类型。

但是那些你不控制的函数呢?

一个常见的例子是第三方库。一个库可能会导出一个你可以使用的函数,但可能不会导出附带的类型。我最近遇到的一个例子是来自 @monaco-editor/react 库的一个类型。

typescript 复制代码
// import { Editor } from "@monaco-editor/react";

// 这是 JSX 组件,对我们来说等同于...
/*
<Editor
  onMount={(editor) => {
    // ...
  }}
/>;
*/

// ...直接用一个对象调用函数
/*
Editor({
  onMount: (editor) => {
    // ...
  },
});
*/

在这种情况下,我想知道 editor 的类型,以便可以在其他地方的函数中重用它。但是 @monaco-editor/react 库没有导出它的类型。

首先,我提取了组件期望的对象类型:

typescript 复制代码
// 假设 Editor 是一个函数组件
// type EditorProps = Parameters<typeof Editor>[0];

然后,我使用索引访问类型提取 onMount 属性的类型:

typescript 复制代码
// type OnMount = EditorProps["onMount"];

最后,我从 OnMount 类型中提取第一个参数以获取 editor 的类型:

typescript 复制代码
// type Editor = Parameters<NonNullable<OnMount>>[0]; // NonNullable 以防 onMount 是可选的

这使我能够在代码的其他地方的函数中重用 Editor 类型。

通过将索引访问类型与 TypeScript 的工具类型相结合,你可以解决第三方库的限制,并确保你的类型与你正在使用的函数保持同步。

练习

练习 7:单一真实来源

这里我们有一个 makeQuery 函数,它接受两个参数:一个 url 和一个可选的 opts 对象。

typescript 复制代码
const makeQuery = (
  url: string,
  opts?: {
    method?: string;
    headers?: {
      [key: string]: string;
    };
    body?: string;
  },
) => {};

我们希望将这些参数指定为一个名为 MakeQueryParameters 的元组,其中元组的第一个参数是字符串,第二个成员是可选的 opts 对象。

手动指定 MakeQueryParameters 大致如下:

typescript 复制代码
type MakeQueryParametersManual = [ // 重命名以避免与目标冲突
  string,
  {
    method?: string;
    headers?: {
      [key: string]: string;
    };
    body?: string;
  }?,
];

除了编写和阅读起来有点麻烦之外,上面的另一个问题是我们现在有两个真实来源:一个是 MakeQueryParameters 类型,另一个是在 makeQuery 函数中。

你的任务是使用一个工具类型来解决这个问题。

练习 8:基于返回值进行类型化

假设我们正在使用来自第三方库的 createUser 函数:

typescript 复制代码
const createUser = (id: string) => {
  return {
    id,
    name: "John Doe",
    email: "example@email.com",
  };
};

为了本练习的目的,假设我们不知道该函数的实现。

目标是创建一个 User 类型,它表示 createUser 函数的返回类型。已经编写了一个测试来检查 User 类型是否匹配:

typescript 复制代码
// import { Equal, Expect } from "@total-typescript/helpers"; // 假设已导入
type User = unknown;

// type test = Expect<
//   Equal< // Type 'false' does not satisfy the constraint 'true'.2344
//     User,
//     {
//       id: string;
//       name: string;
//       email: string;
//     }
//   >
// >;

你的任务是更新 User 类型,以便测试按预期通过。

练习 9:解包 Promise

这次来自第三方库的 createUser 函数是异步的(重命名为 fetchUser 以匹配上下文):

typescript 复制代码
const fetchUser = async (id: string) => {
  return {
    id,
    name: "John Doe",
    email: "example@email.com",
  };
};

// import { Equal, Expect } from "@total-typescript/helpers"; // 假设已导入
// type User = unknown; // 假设 User 在这里定义
// type test = Expect<
//   Equal< // Type 'false' does not satisfy the constraint 'true'.2344
//     User, // Cannot find name 'User'.2304
//     {
//       id: string;
//       name: string;
//       email: string;
//     }
//   >
// >;

和以前一样,假设你无法访问 fetchUser 函数的实现。

你的任务是更新 User 类型,以便测试按预期通过。

解决方案 7:单一真实来源

Parameters 工具类型是此解决方案的关键,但还需要一个额外的步骤。

直接将 makeQuery 传递给 Parameters 本身是行不通的,因为 makeQuery 是一个值而不是一个类型:

typescript 复制代码
// type MakeQueryParameters = Parameters<makeQuery>;
// 'makeQuery' refers to a value, but is being used as a type here. Did you mean 'typeof makeQuery'?2749
// 'makeQuery' 指向一个值,但在这里被用作类型。你是想用 'typeof makeQuery' 吗?2749

正如错误消息所建议的,我们需要使用 typeofmakeQuery 函数创建一个类型,然后将该类型传递给 Parameters

typescript 复制代码
// const makeQuery = ( /* ... */ ) => {}; // 已定义
type MakeQueryParameters = Parameters<typeof makeQuery>;

我们现在有了 MakeQueryParameters,它表示一个元组,其中第一个成员是 url 字符串,第二个成员是可选的 opts 对象。

对类型进行索引将允许我们创建一个表示 opts 对象的 Opts 类型:

typescript 复制代码
type Opts = MakeQueryParameters[1];

解决方案 8:基于返回值进行类型化

使用 ReturnType 工具类型,我们可以从 createUser 函数中提取返回类型并将其分配给一个新类型。请记住,由于 createUser 是一个值,我们需要使用 typeof 从中创建一个类型:

typescript 复制代码
// const createUser = (id: string) => { /* ... */ }; // 已定义
type User = ReturnType<typeof createUser>;
/*
type User = {
    id: string;
    name: string;
    email: string;
}
*/

这个 User 类型与期望的类型匹配,这意味着我们的测试将按预期通过。

解决方案 9:解包 Promise

当将 ReturnType 工具类型与异步函数一起使用时,结果类型将被包装在 Promise 中:

typescript 复制代码
// const fetchUser = async (id: string) => { /* ... */ }; // 已定义
type UserPromise = ReturnType<typeof fetchUser>;
/*
type UserPromise = Promise<{ // 重命名以避免与目标冲突
    id: string;
    name: string;
    email: string;
}>
*/

为了解包 Promise 类型并提供已解析值的类型,我们可以使用 Awaited 工具类型:

typescript 复制代码
type User = Awaited<ReturnType<typeof fetchUser>>;
/*
type User = {
    id: string;
    name: string;
    email: string;
}
*/

和以前一样,User 类型现在与期望的类型匹配,这意味着我们的测试将按预期通过。

也可以创建中间类型,但结合操作符和类型派生为我们提供了一个更简洁的解决方案。

转换派生类型

在上一节中,我们研究了如何从你不控制的函数派生类型。有时,你还需要对你不控制的类型执行相同的操作。

Exclude

Exclude 工具类型用于从联合类型中移除某些类型。让我们想象一下,我们有一个表示专辑可能处于的不同状态的联合类型:

typescript 复制代码
type AlbumState =
  | {
      type: "released";
      releaseDate: string;
    }
  | {
      type: "recording";
      studio: string;
    }
  | {
      type: "mixing";
      engineer: string;
    };

我们想创建一个表示非"released"状态的类型。我们可以使用 Exclude 工具类型来实现这一点:

typescript 复制代码
type UnreleasedState = Exclude<AlbumState, { type: "released" }>;
/*
type UnreleasedState = {
    type: "recording";
    studio: string;
} | {
    type: "mixing";
    engineer: string;
}
*/

在这种情况下,UnreleasedState 类型是 recordingmixing 状态的联合,这些状态不是"released"。Exclude 过滤掉联合中任何 typereleased 的成员。

我们也可以通过检查 releaseDate 属性来做到这一点:

typescript 复制代码
// type UnreleasedState = Exclude<AlbumState, { releaseDate: string }>;
// 这不会按预期工作,因为 { releaseDate: string } 无法分配给 { type: "released"; releaseDate: string; }。
// Exclude 在这里会移除匹配整个形状的成员,而不是仅包含该属性的成员。
// 为了精确匹配,你需要提供一个可分配给联合成员的类型。
// 例如 Exclude<AlbumState, { type: "released", releaseDate: string }> 是有效的。
// 或者,更常见的是,Exclude<AlbumState, { type: "released" }>。

Exclude<U, T> 通过从 U 中排除所有可分配给 T 的联合成员来构造类型。

这意味着我们可以用它来从联合类型中移除所有字符串:

typescript 复制代码
type Example = "a" | "b" | 1 | 2;
type Numbers = Exclude<Example, string>; // 1 | 2

NonNullable

NonNullable<T> 用于从类型 T 中移除 nullundefined。这在从部分对象中提取类型时非常有用:

typescript 复制代码
type Album = {
  artist?: {
    name: string;
  };
};

type Artist = NonNullable<Album["artist"]>;
/*
type Artist = {
    name: string;
}
*/

这与 Exclude 的操作类似:

typescript 复制代码
// type Artist = Exclude<Album["artist"], null | undefined>;

但是 NonNullable 更明确且更易于阅读。

Extract

Extract<T, U>Exclude 的反操作。它用于从联合类型 T 中提取所有可分配给 U 的联合成员。例如,我们可以使用 ExtractAlbumState 类型中提取 recording 状态:

typescript 复制代码
type RecordingState = Extract<AlbumState, { type: "recording" }>;
/*
type RecordingState = {
    type: "recording";
    studio: string;
}
*/

当你想从你不控制的联合类型中提取特定类型时,这很有用。

Exclude 类似,Extract 通过模式匹配工作。它将从联合中提取任何与你提供的模式匹配的类型。

这意味着,要反转我们之前的 Extract 示例,我们可以用它来从联合中提取所有字符串:

typescript 复制代码
type Example = "a" | "b" | 1 | 2 | true | false;
type Strings = Extract<Example, string>; // "a" | "b"

值得注意 Exclude / ExtractOmit/Pick 之间的相似之处。一个常见的错误是认为你可以从联合类型中 Pick,或者在对象上使用 Exclude。这里有一个小表格可以帮助你记住:

名称 用于 操作 示例
Exclude 联合类型 排除成员 `Exclude<'a'
Extract 联合类型 提取成员 `Extract<'a'
Omit 对象类型 排除属性 Omit<UserObj, 'id'>
Pick 对象类型 提取属性 Pick<UserObj, 'id'>

派生与解耦

感谢这些章节中的工具,我们现在知道如何从各种来源派生类型:函数、对象和类型。但是在派生类型时需要考虑一个权衡:耦合。

当你从一个源派生一个类型时,你正在将派生类型与该源耦合。如果你从另一个派生类型派生一个类型,这可能会在你的应用程序中创建很长的耦合链,这些耦合链可能难以管理。

何时解耦有意义

让我们想象一下,在 db.ts 文件中我们有一个 User 类型:

typescript 复制代码
// db.ts
export type User = {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
};

在这个例子中,我们假设我们正在使用像 React、Vue 或 Svelte 这样的基于组件的框架。我们有一个 AvatarImage 组件,用于渲染用户的图像。我们可以直接传入 User 类型:

typescript 复制代码
import { User } from "./db";

export const AvatarImage = (props: { user: User }) => {
  // return <img src={props.user.imageUrl} alt={props.user.name} />;
  return null; // 占位符
};

但事实证明,我们只使用了 User 类型中的 imageUrlname 属性。让你的函数和组件只请求它们运行所需的数据是一个好主意。这有助于防止你传递不必要的数据。

让我们尝试派生。我们将创建一个名为 AvatarImageProps 的新类型,它只包含我们需要的属性:

typescript 复制代码
import { User } from "./db";

type AvatarImageProps = Pick<User, "imageUrl" | "name">;

但是让我们思考一下。我们现在已将 AvatarImageProps 类型与 User 类型耦合。AvatarImageProps 现在不仅依赖于 User 的形状,还依赖于它在 db.ts 文件中的存在 。这意味着如果我们 कभी移动 User 类型的位置,或者将其拆分为单独的接口,我们就需要考虑 AvatarImageProps

让我们尝试另一种方式。与其从 User 派生 AvatarImageProps,不如将它们解耦。我们将创建一个新类型,它只包含我们需要的属性:

typescript 复制代码
type AvatarImagePropsDecoupled = { // 重命名以避免与上面的冲突
  imageUrl: string;
  name: string;
};

现在,AvatarImagePropsDecoupledUser 解耦了。我们可以移动 User,将其拆分为单独的接口,甚至删除它,AvatarImagePropsDecoupled 都不会受到影响。

在这种特殊情况下,解耦似乎是正确的选择。这是因为 UserAvatarImage 是独立的关注点。User 是一个数据类型,而 AvatarImage 是一个 UI 组件。它们有不同的职责和不同的变更原因。通过解耦它们,AvatarImage 变得更具可移植性且更易于维护。

使解耦成为一个困难决定的原因是,派生会让你感觉"聪明"。Pick 诱惑我们,因为它使用了 TypeScript 的一个更高级的功能,这让我们为应用了所学知识而感觉良好。但通常情况下,做简单的事情,保持类型解耦,是更明智的选择。

何时派生有意义

当你要耦合的代码共享一个共同的关注点时,派生最有意义。本章中的例子就是很好的例证。例如,我们的 as const 对象:

typescript 复制代码
const albumTypes = {
  CD: "cd",
  VINYL: "vinyl",
  DIGITAL: "digital",
} as const;

type AlbumType = (typeof albumTypes)[keyof typeof albumTypes];

在这里,AlbumType 是从 albumTypes 派生的。如果我们将其解耦,我们将不得不维护两个密切相关的真实来源:

typescript 复制代码
// type AlbumTypeDecoupled = "cd" | "vinyl" | "digital"; // 如果解耦

因为 AlbumTypealbumTypes 密切相关,所以从 albumTypes 派生 AlbumType 是有意义的。

另一个例子是一种类型与另一种类型直接相关。例如,我们的 User 类型可能有一个从它派生出来的 UserWithoutId 类型:

typescript 复制代码
type User = {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
};

type UserWithoutId = Omit<User, "id">;

const updateUser = (id: string, user: UserWithoutId) => {
  // ...
};

同样,这些关注点是密切相关的。解耦它们会使我们的代码更难维护,并在我们的代码库中引入更多的重复工作。

决定派生还是解耦,关键在于减少你未来的工作量。

这两种类型是否如此相关,以至于对一个类型的更新需要传递到另一个类型?那么就派生。

它们是否如此不相关,以至于耦合它们可能会导致将来更多的工作?那么就解耦。

相关推荐
jonjia2 小时前
开发流程中的 TypeScript
typescript
jonjia2 小时前
设计你的类型
typescript
jonjia2 小时前
Total TypeScript 精要
typescript
牛奶1 天前
ts随笔:面向对象与高级类型
前端·面试·typescript
SuperEugene1 天前
接口类型管理:从 any 到有组织的 api.d.ts
前端·面试·typescript
牛奶1 天前
ts随笔:基础与类型系统
前端·面试·typescript
随逸1771 天前
《从零搭建NestJS项目》
数据库·typescript
唐璜Taro2 天前
Vue3 + TypeScript 后台管理系统完整方案
前端·javascript·typescript
Wect2 天前
LeetCode 530. 二叉搜索树的最小绝对差:两种解法详解(迭代+递归)
前端·算法·typescript