可变性

5 阅读1分钟

可变性

探索 TypeScript 的可变性与推断:变量声明、对象属性推断、只读属性以及使用 'as const' 实现深度不可变性。

7

在我们关于联合类型和类型收窄的章节中,我们探讨了 TypeScript 如何从代码的逻辑流程中推断类型。在本章中,我们将了解可变性——即值是否可以被改变——如何影响类型推断。

可变性与推断

变量声明与类型推断

在 TypeScript 中,你如何声明变量会影响它们是否可以被更改。

TypeScript 如何推断 let

当使用 let 关键字时,变量是可变的并且可以被重新赋值。

考虑这个 AlbumGenre 类型:一个表示专辑可能流派的字面量值的联合类型:

type AlbumGenre = "rock" | "country" | "electronic";

使用 let,我们可以声明一个变量 albumGenre 并将其赋值为 "rock"。然后我们尝试将 albumGenre 传递给一个期望 AlbumGenre 类型的函数:

let albumGenre = "rock";

const handleGenre = (genre: AlbumGenre) => {
  // ...
};

handleGenre(albumGenre);
// Argument of type 'string' is not assignable to parameter of type 'AlbumGenre'.2345
// 类型 'string' 的参数不能赋给类型 'AlbumGenre' 的参数。2345

因为在声明变量时使用了 let,TypeScript 理解该值后续可以被更改。在这种情况下,它将 albumGenre 推断为 string 类型,而不是特定的字面量类型 "rock"。在我们的代码中,我们可以这样做:

albumGenre = "country";

因此,它会推断一个更宽泛的类型,以适应变量被重新赋值的情况。

我们可以通过为 let 变量指定一个特定的类型来修复上面的错误:

let albumGenre: AlbumGenre = "rock";

const handleGenre = (genre: AlbumGenre) => {
  // ...
};

handleGenre(albumGenre); // 不再报错

现在,albumGenre 可以被重新赋值,但只能赋值为 AlbumGenre 联合类型中的成员。因此,当传递给 handleGenre 时,它将不再显示错误。

但还有另一个有趣的解决方案。

TypeScript 如何推断 const

当使用 const 时,变量是不可变的并且不能被重新赋值。当我们将变量声明更改为使用 const 时,TypeScript 会更窄地推断类型:

const albumGenre = "rock";

const handleGenre = (genre: AlbumGenre) => {
  // ...
};

handleGenre(albumGenre); // 没有错误

赋值不再有错误,并且将鼠标悬停在 albumDetails 对象内部的 albumGenre 上,会显示 TypeScript 已将其推断为字面量类型 "rock"

如果我们尝试在将 albumGenre 声明为 const 后更改其值,TypeScript 将显示错误:

albumGenre = "country";
// Cannot assign to 'albumGenre' because it is a constant.2588
// 无法赋值给 'albumGenre',因为它是一个常量。2588

TypeScript 正在模仿 JavaScript 对 const 的处理方式,以防止潜在的运行时错误。当你用 const 声明一个变量时,TypeScript 会将其推断为你指定的字面量类型。

因此,TypeScript 利用 JavaScript 的工作方式为其优势。这通常会鼓励你在声明变量时使用 const 而不是 let,因为它更严格一些。

对象属性推断

当涉及到对象属性时,constlet 的情况变得稍微复杂一些。

在 JavaScript 中,对象是可变的,这意味着它们的属性在创建后可以被更改。

对于这个例子,我们有一个 AlbumAttributes 类型,它包含一个 status 属性,该属性是一个表示可能专辑状态的字面量值的联合类型:

type AlbumAttributes = {
  status: "new-release" | "on-sale" | "staff-pick";
};

假设我们有一个 updateStatus 函数,它接受一个 AlbumAttributes 对象:

const updateStatus = (attributes: AlbumAttributes) => {
  // ...
};

const albumAttributes = {
  status: "on-sale",
};

updateStatus(albumAttributes);
// Argument of type '{ status: string; }' is not assignable to parameter of type 'AlbumAttributes'.
//   Types of property 'status' are incompatible.
//     Type 'string' is not assignable to type '"new-release" | "on-sale" | "staff-pick"'.2345
// 类型 '{ status: string; }' 的参数不能赋给类型 'AlbumAttributes' 的参数。
//   属性 'status' 的类型不兼容。
//     类型 'string' 不能赋给类型 '"new-release" | "on-sale" | "staff-pick"'。2345

TypeScript 在 updateStatus 函数调用内部的 albumAttributes 下方给出了一个错误,信息与我们之前看到的类似。

这是因为 TypeScript 已将 status 属性推断为 string 类型,而不是特定的字面量类型 "on-sale"。与 let 类似,TypeScript 理解该属性后续可以被重新赋值:

albumAttributes.status = "new-release";

即使 albumAttributes 对象是用 const 声明的,这也是如此。我们在调用 updateStatus 时遇到错误,因为 status: string 不能传递给期望 status: "new-release" | "on-sale" | "staff-pick" 的函数。TypeScript 试图保护我们免受潜在的运行时错误的影响。

让我们看看几种解决这个问题的方法。

使用内联对象

一种方法是在调用 updateStatus 函数时内联对象,而不是单独声明它:

updateStatus({
  status: "on-sale",
}); // 没有错误

当内联对象时,TypeScript 知道 status 在传递给函数之前不可能被更改,因此它会更窄地推断它。

为对象添加类型

另一种选择是显式声明 albumAttributes 对象的类型为 AlbumAttributes

const albumAttributes: AlbumAttributes = {
  status: "on-sale",
};

updateStatus(albumAttributes); // 没有错误

这与它在 let 中的工作方式类似。虽然 albumAttributes.status 仍然可以被重新赋值,但它只能被重新赋值为一个有效的值:

albumAttributes.status = "new-release"; // 没有错误

这种行为对所有类对象结构(包括数组和元组)都同样适用。我们稍后将在练习中研究这些。

只读对象属性

在 JavaScript 中,正如我们所见,对象属性默认是可变的。但是 TypeScript 允许我们更具体地说明对象的属性是否可以被修改。

要使属性只读(不可写),你可以使用 readonly 修饰符:

考虑这个 Album 接口,其中 titleartist 被标记为 readonly

interface Album {
  readonly title: string;
  readonly artist: string;
  status?: "new-release" | "on-sale" | "staff-pick";
  genre?: string[];
}

一旦创建了 Album 对象,其 titleartist 属性就被锁定,不能更改。然而,可选的 statusgenre 属性仍然可以修改。

请注意,这仅发生在类型层面。在运行时,属性仍然是可变的。TypeScript 只是在帮助我们捕获潜在的错误。

Readonly 类型助手

如果你想指定一个对象的所有属性都应该是只读的,TypeScript 提供了一个名为 Readonly 的类型助手。

要使用它,你只需用 Readonly 包装对象类型。

这是一个使用 Readonly 创建 Album 对象的示例:

const readOnlyWhiteAlbum: Readonly<Album> = {
  title: "The Beatles (White Album)",
  artist: "The Beatles",
  status: "staff-pick",
};

因为 readOnlyWhiteAlbum 对象是使用 Readonly 类型助手创建的,所以它的任何属性都不能被修改:

readOnlyWhiteAlbum.genre = ["rock", "pop", "unclassifiable"];
// Cannot assign to 'genre' because it is a read-only property.2540
// 无法赋值给 'genre',因为它是只读属性。2540

请注意,与 TypeScript 的许多类型助手一样,Readonly 强制执行的不可变性仅作用于第一层。它不会递归地使属性只读。

只读数组

与对象属性一样,数组和元组也可以通过使用 readonly 修饰符使其不可变。

以下是如何使用 readonly 修饰符创建只读流派数组。一旦数组被创建,其内容就不能被修改:

const readOnlyGenres: readonly string[] = ["rock", "pop", "unclassifiable"];

Array 语法类似,TypeScript 还提供了一个 ReadonlyArray 类型助手,其功能与上述语法相同:

const readOnlyGenres: ReadonlyArray<string> = ["rock", "pop", "unclassifiable"];

这两种方法在功能上是相同的。将鼠标悬停在 readOnlyGenres 变量上,会显示 TypeScript 已将其推断为只读数组:

// 鼠标悬停在 `readOnlyGenres` 上显示:
const readOnlyGenres: readonly string[];

只读数组不允许使用会导致突变的数组方法,例如 pushpop

readOnlyGenres.push("experimental");
// Property 'push' does not exist on type 'readonly string[]'.2339
// 属性 'push' 在类型 'readonly string[]' 上不存在。2339

然而,像 mapreduce 这样的方法仍然有效,因为它们会创建数组的副本并且不会改变原始数组。

const uppercaseGenres = readOnlyGenres.map((genre) => genre.toUpperCase()); // 没有错误

readOnlyGenres.push("experimental");
// Property 'push' does not exist on type 'readonly string[]'.2339
// 属性 'push' 在类型 'readonly string[]' 上不存在。2339

请注意,就像对象属性的 readonly 一样,这不会影响数组的运行时行为。这只是一种帮助捕获潜在错误的方法。

只读数组和可变数组如何协同工作

为了帮助大家更好地理解这个概念,让我们看看只读数组和可变数组是如何协同工作的。

这里有两个 printGenre 函数,它们在功能上是相同的,除了 printGenresReadOnly 接受一个只读的流派数组作为参数,而 printGenresMutable 接受一个可变的数组:

function printGenresReadOnly(genres: readonly string[]) {
  // ...
}

function printGenresMutable(genres: string[]) {
  // ...
}

当我们创建一个可变的流派数组时,它可以作为参数传递给这两个函数而不会出错:

const mutableGenres = ["rock", "pop", "unclassifiable"];

printGenresReadOnly(mutableGenres);
printGenresMutable(mutableGenres);

这是可行的,因为在 printGenresReadOnly 函数参数上指定 readonly 仅保证它不会改变数组的内容。因此,即使我们传递一个可变数组也没关系,因为它不会被更改。

然而,反过来则不然。

如果我们声明一个只读数组,我们只能将它传递给 printGenresReadOnly。尝试将其传递给 printGenresMutable 将产生错误:

const readOnlyGenres: readonly string[] = ["rock", "pop", "unclassifiable"];

printGenresReadOnly(readOnlyGenres);
printGenresMutable(readOnlyGenres);
// Argument of type 'readonly string[]' is not assignable to parameter of type 'string[]'.
//   The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.2345
// 类型 'readonly string[]' 的参数不能赋给类型 'string[]' 的参数。
//   类型 'readonly string[]' 是 'readonly' 的,不能赋给可变类型 'string[]'。2345

这是因为我们可能会在 printGenresMutable 内部改变数组。如果我们传递了一个只读数组,(这就不允许了)。

本质上,只读数组只能赋值给其他只读类型。这可能会在你的应用程序中连锁式地(virally)传播:如果调用栈深处的某个函数期望一个 readonly 数组,那么该数组必须始终保持 readonly。但这样做会带来好处。它确保了数组在向下传递的过程中不会以任何方式被改变。非常有用。

这里的要点是,即使你可以将可变数组赋值给只读数组,你也无法将只读数组赋值给可变数组。

练习

练习 1:对象数组的类型推断

这里我们有一个 modifyButtons 函数,它接收一个对象数组,这些对象的 type 属性是 "button""submit""reset" 之一。

当尝试使用一个看起来符合约定的对象数组调用 modifyButtons 时,TypeScript 给出了一个错误:

type ButtonAttributes = {
  type: "button" | "submit" | "reset";
};

const modifyButtons = (attributes: ButtonAttributes[]) => {};

const buttonsToChange = [
  {
    type: "button",
  },
  {
    type: "submit",
  },
];

modifyButtons(buttonsToChange);
// Argument of type '{ type: string; }[]' is not assignable to parameter of type 'ButtonAttributes[]'.
//   Type '{ type: string; }' is not assignable to type 'ButtonAttributes'.
//     Types of property 'type' are incompatible.
//       Type 'string' is not assignable to type '"button" | "submit" | "reset"'.2345
// 类型 '{ type: string; }[]' 的参数不能赋给类型 'ButtonAttributes[]' 的参数。
//   类型 '{ type: string; }' 不能赋给类型 'ButtonAttributes'。
//     属性 'type' 的类型不兼容。
//       类型 'string' 不能赋给类型 '"button" | "submit" | "reset"'。2345

你的任务是确定此错误出现的原因,然后解决它。

练习 1:对象数组的类型推断

练习 2:避免数组突变

这个 printNames 函数接受一个 name 字符串数组,并将它们记录到控制台。然而,还有一些不起作用的 @ts-expect-error 注释,它们本应不允许添加或更改名称:

function printNames(names: string[]) {
  for (const name of names) {
    console.log(name);
  }

  // @ts-expect-error
// Unused '@ts-expect-error' directive.2578  names.push("John");
// 未使用的 '@ts-expect-error' 指令。2578  names.push("John");

  // @ts-expect-error
// Unused '@ts-expect-error' directive.2578  names[0] = "Billy";
// 未使用的 '@ts-expect-error' 指令。2578  names[0] = "Billy";
}

你的任务是更新 names 参数的类型,以便数组不能被突变。有两种方法可以解决此问题。

练习 2:避免数组突变

练习 3:不安全的元组

这里我们有一个 dangerousFunction,它接受一个数字数组作为参数:

const dangerousFunction = (arrayOfNumbers: number[]) => {
  arrayOfNumbers.pop();
  arrayOfNumbers.pop();
};

此外,我们定义了一个变量 myHouse,它是一个表示 Coordinate 的元组:

type Coordinate = [number, number];

const myHouse: Coordinate = [0, 0];

我们的元组 myHouse 包含两个元素,而 dangerousFunction 的结构是会从给定数组中弹出两个元素。

鉴于 pop 会移除数组的最后一个元素,用 myHouse 调用 dangerousFunction 将会移除其内容。

目前,TypeScript 没有就这个潜在问题向我们发出警报,正如 @ts-expect-error 下的错误行所示:

dangerousFunction(
  // @ts-expect-error
// Unused '@ts-expect-error' directive.2578  myHouse,
// 未使用的 '@ts-expect-error' 指令。2578  myHouse,
);

你的任务是调整 Coordinate 的类型,以便当我们尝试将 myHouse 传递给 dangerousFunction 时,TypeScript 会触发一个错误。

请注意,你只应更改 Coordinate,并保持函数不变。

练习 3:不安全的元组

解决方案 1:对象数组的类型推断

将鼠标悬停在 buttonsToChange 变量上,会显示它被推断为一个对象数组,其中对象的 type 属性的类型为 string

// 鼠标悬停在 buttonsToChange 上显示:
const buttonsToChange: {
  type: string;
}[];

发生这种推断是因为我们的数组是可变的。我们可以将数组中第一个元素的类型更改为其他内容:

buttonsToChange[0].type = "something strange";

这种更宽泛的类型与 ButtonAttributes 类型不兼容,后者期望 type 属性是 "button""submit""reset" 之一。

这里的解决方法是指定 buttonsToChange 是一个 ButtonAttributes 数组:

type ButtonAttributes = {
  type: "button" | "submit" | "reset";
};

const modifyButton = (attributes: ButtonAttributes[]) => {};

const buttonsToChange: ButtonAttributes[] = [
  {
    type: "button",
  },
  {
    type: "submit",
  },
];

modifyButtons(buttonsToChange); // 没有错误

或者,我们可以将数组直接传递给 modifyButtons 函数:

modifyButtons([
  {
    type: "button",
  },
  {
    type: "submit",
  },
]); // 没有错误

通过这样做,TypeScript 将更窄地推断 type 属性,错误将会消失。

解决方案 2:避免数组突变

这里有几种解决这个问题的方法。

方法 1:添加 readonly 关键字

第一种解决方案是在 string[] 数组之前添加 readonly 关键字。它适用于整个 string[] 数组,将其转换为只读数组:

function printNames(names: readonly string[]) {
  // ...
}

通过此设置,TypeScript 将不允许你使用 .push() 添加项目或对数组执行任何其他修改。

方法 2:使用 ReadonlyArray 类型助手

或者,你可以使用 ReadonlyArray 类型助手:

function printNames(names: ReadonlyArray<string>) {
  // ...
}

无论你使用这两种方法中的哪一种,当鼠标悬停在 names 参数上时,TypeScript 仍会显示 readonly string[]

// 鼠标悬停在 `names` 上显示:
(parameter) names: readonly string[]

两者在防止数组被修改方面同样有效。

解决方案 3:不安全的元组

防止对 Coordinate 元组进行不必要更改的最佳方法是使其成为 readonly 元组:

type Coordinate = readonly [number, number];

现在,当我们尝试将 myHouse 传递给 dangerousFunction 时,dangerousFunction 会抛出一个 TypeScript 错误:

const dangerousFunction = (arrayOfNumbers: number[]) => {
  arrayOfNumbers.pop();
  arrayOfNumbers.pop();
};

dangerousFunction(myHouse);
// Argument of type 'Coordinate' is not assignable to parameter of type 'number[]'.
//   The type 'Coordinate' is 'readonly' and cannot be assigned to the mutable type 'number[]'.2345
// 类型 'Coordinate' 的参数不能赋给类型 'number[]' 的参数。
//   类型 'Coordinate' 是 'readonly' 的,不能赋给可变类型 'number[]'。2345

我们收到一个错误,因为函数的签名期望一个可修改的数字数组,但 myHouse 是一个只读元组。TypeScript 正在保护我们免受不必要的更改。

尽可能多地使用 readonly 元组是一个好习惯,以避免像本练习中这样的问题。

使用 as const 实现深度不可变性

到目前为止,我们已经看到对象和数组在 JavaScript 中是可变的。这导致它们的属性被 TypeScript 宽泛地推断。

我们可以通过给属性添加类型注解来解决这个问题。但它仍然不能推断属性的字面量类型。

const albumAttributes: AlbumAttributes = {
  status: "on-sale",
};

// 鼠标悬停在 albumAttributes 上显示:
const albumAttributes: {
  status: "new-release" | "on-sale" | "staff-pick";
};

albumAttributes.status 被推断为 "new-release" | "on-sale" | "staff-pick",而不是 "on-sale"

我们可以让 TypeScript 正确推断的一种方法是,以某种方式将整个对象及其所有属性标记为不可变的。这将告诉 TypeScript 该对象及其属性不能被更改,因此它可以自由地推断属性的字面量类型。

这就是 as const 断言发挥作用的地方。我们可以用它来将一个对象及其所有属性标记为常量,这意味着它们一旦创建就不能被更改。

const albumAttributes = {
  status: "on-sale",
} as const;

// 鼠标悬停在 albumAttributes 上显示:
const albumAttributes: {
  readonly status: "on-sale";
};

as const 断言使整个对象及其所有属性都变为深度只读。这意味着 albumAttributes.status 现在被推断为字面量类型 "on-sale"

尝试更改 status 属性将导致错误:

albumAttributes.status = "new-release";
// Cannot assign to 'status' because it is a read-only property.2540
// 无法赋值给 'status',因为它是只读属性。2540

这使得 as const 非常适用于那些你不希望改变的大型配置对象。

就像 readonly 修饰符一样,as const 只影响类型层面。在运行时,对象及其属性仍然是可变的。

as const 与变量注解

你可能想知道如果我们将 as const 与变量注解结合起来会发生什么。它将如何被推断?

const albumAttributes: AlbumAttributes = {
  status: "on-sale",
} as const;

你可以将此代码视为两种力量之间的竞争:作用于值的 as const 断言,以及作用于变量的注解。

当存在这样的竞争时,变量注解会胜出。变量拥有该值,并会忘记之前显式声明的值是什么。

这意味着,奇怪的是,status 属性被推断为可变的:

albumAttributes.status = "new-release"; // 没有错误

as const 断言被变量注解覆盖了。这不太好。

我们将在关于注解和断言的章节中进一步探讨变量和值之间的这种交互。

as constObject.freeze 的比较

在 JavaScript 中,Object.freeze 方法是一种在运行时创建不可变对象的方法。Object.freezeas const 之间存在一些显著差异。

对于这个例子,我们将创建一个使用 Object.freezeshelfLocations 对象:

const shelfLocations = Object.freeze({
  entrance: {
    status: "on-sale",
  },
  frontCounter: {
    status: "staff-pick",
  },
  endCap: {
    status: "new-release",
  },
});

将鼠标悬停在 shelfLocations 上显示该对象已应用 Readonly 修饰符:

// 鼠标悬停在 shelfLocations 上显示:
const shelfLocations: Readonly<{
  entrance: {
    status: string;
  };
  frontCounter: {
    status: string;
  };
  endCap: {
    status: string;
  };
}>;

回想一下,Readonly 修饰符仅对对象的第一层有效。如果我们尝试修改 frontCounter 属性,TypeScript 将抛出错误:

shelfLocations.frontCounter = {
// Cannot assign to 'frontCounter' because it is a read-only property.2540
// 无法赋值给 'frontCounter',因为它是只读属性。2540
  status: "new-release",
};

然而,我们能够更改特定位置的嵌套 status 属性:

shelfLocations.entrance.status = "new-release";

这与 Object.freeze 在 JavaScript 中的工作方式一致。它只在第一层使对象及其属性只读。它不会使整个对象深度只读。

使用 as const 会使整个对象(包括所有嵌套属性)深度只读:

const shelfLocations = {
  entrance: {
    status: "on-sale",
  },
  frontCounter: {
    status: "staff-pick",
  },
  endCap: {
    status: "new-release",
  },
} as const;

console.log(shelfLocations);
//                   const shelfLocations: {
//     readonly entrance: {
//         readonly status: "on-sale";
//     };
//     readonly frontCounter: {
//         readonly status: "staff-pick";
//     };
//     readonly endCap: {
//         readonly status: "new-release";
//     };
// }

当然,这只是一个类型层面的注解。Object.freeze 提供了运行时不可变性,而 as const 提供了类型层面不可变性。我实际上更喜欢后者——在运行时做更少的工作总是一件好事。

因此,虽然 as constObject.freeze 都会强制实现不可变性,但 as const 是更方便、更高效的选择。除非你特别需要在运行时冻结对象的顶层,否则你应该坚持使用 as const

练习

练习 1:从函数返回元组

在本练习中,我们处理一个名为 fetchData 的异步函数,该函数从 URL 获取数据并返回结果。

无论函数成功还是失败,它都会返回一个元组。如果获取操作失败,元组的第一个成员包含错误消息;如果操作成功,第二个成员包含获取的数据。

以下是该函数当前的实现方式:

const fetchData = async () => {
  const result = await fetch("/");
  if (!result.ok) {
    return [new Error("Could not fetch data.")];
  }
  const data = await result.json();
  return [undefined, data];
};

这是一个使用 fetchData 的异步 example 函数,并包含几个测试用例:

const example = async () => {
  const [error, data] = await fetchData();
  type Tests = [
    Expect<Equal<typeof error, Error | undefined>>,
// Type 'false' does not satisfy the constraint 'true'.2344
// 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof data, any>>,
  ];
};

目前,元组的两个成员都被推断为 any,这并不理想。

const [error, data] = await fetchData();

// 鼠标悬停在 error 和 data 上显示:
const error: any;
const data: any;

你的挑战是修改 fetchData 函数的实现,以便 TypeScript 为其返回类型推断出一个带有元组的 Promise。

根据获取操作是否成功,元组应包含错误消息,或一对 undefined 和获取的数据。

提示:有两种可能的方法来解决这个挑战。一种方法是为函数定义一个显式的返回类型。或者,你可以尝试在函数内部为 return 值添加或更改类型注解。

练习 1:从函数返回元组

练习 2:推断数组中的字面量值

让我们回顾一下之前的练习并改进我们的解决方案。

modifyButtons 函数接受一个带有 type 属性的对象数组:

type ButtonAttributes = {
  type: "button" | "submit" | "reset";
};

const modifyButtons = (attributes: ButtonAttributes[]) => {};

const buttonsToChange = [
  {
    type: "button",
  },
  {
    type: "submit",
  },
];

modifyButtons(buttonsToChange);
// Argument of type '{ type: string; }[]' is not assignable to parameter of type 'ButtonAttributes[]'.
//   Type '{ type: string; }' is not assignable to type 'ButtonAttributes'.
//     Types of property 'type' are incompatible.
//       Type 'string' is not assignable to type '"button" | "submit" | "reset"'.2345
// 类型 '{ type: string; }[]' 的参数不能赋给类型 'ButtonAttributes[]' 的参数。
//   类型 '{ type: string; }' 不能赋给类型 'ButtonAttributes'。
//     属性 'type' 的类型不兼容。
//       类型 'string' 不能赋给类型 '"button" | "submit" | "reset"'。2345

之前,通过将 buttonsToChange 更新为指定为 ButtonAttributes 数组来解决该错误:

const buttonsToChange: ButtonAttributes[] = [
  {
    type: "button",
  },
  {
    type: "submit",
  },
];

这一次,你的挑战是通过找到不同的解决方案来解决错误。具体来说,你应该修改 buttonsToChange 数组,以便 TypeScript 推断 type 属性的字面量类型。

你不应更改 ButtonAttributes 类型定义或 modifyButtons 函数。

练习 2:推断数组中的字面量值

解决方案 1:从函数返回元组

如前所述,这个挑战有两种不同的解决方案。

方法 1:定义返回类型

第一个解决方案是为 fetchData 函数定义一个返回类型。

Promise 类型内部,定义了一个元组,其第一个成员是 Errorundefined,第二个成员是可选的 any

const fetchData = async (): Promise<[Error | undefined, any?]> => {
  // ...
}

这种技术工作得非常好。

方法 2:使用 as const

第二个解决方案不是指定返回类型,而是在返回值上使用 as const

import { Equal, Expect } from "@total-typescript/helpers";

const fetchData = async () => {
  const result = await fetch("/");
  if (!result.ok) {
    return [new Error("Could not fetch data.")] as const; // 在此处添加 as const
  }
  const data = await result.json();
  return [undefined, data] as const; // 在此处添加 as const
};

完成这些更改后,当我们在 example 函数中检查 fetchData 的返回类型时,可以看到 error 被推断为 Error | undefined,而 dataany

const example = async () => {
  const [error, data] = await fetchData();
  // ...
};

// 鼠标悬停在 error 上显示:
const error: Error | undefined;

// 鼠标悬停在 data 上显示:
const data: any;

在这个挑战的情况下,如果没有 as const,TypeScript 会犯两个错误。首先,它将每个返回的数组推断为数组,而不是元组。这是 TypeScript 的默认行为:

const data = await result.json();
const result = [undefined, data];

// 鼠标悬停在 result 上显示:
const result: any[];

我们还可以看到,当 undefinedany 一起放入数组时,TypeScript 会将该数组推断为 any[]。这是 TypeScript 的第二个错误——它压缩了我们的 undefined 值,使其几乎消失。

然而,通过使用 as const,TypeScript 正确地将返回值推断为元组 (Promise<[Error | undefined, any]>)。这是一个很好的例子,说明 as const 如何帮助 TypeScript 为我们提供最佳的类型推断。 (译者注:原文此处推断结果为 Promise<[string | undefined, any]>,根据上下文及 as const 的行为,当 new Error(...) 作为元组一部分并使用 as const 时,其类型应为 Error 而非 string。推测原文可能有笔误,故此处译为 Promise<[Error | undefined, any]> 以符合实际行为。)

解决方案 2:推断数组中的字面量值

让我们看看解决这个挑战的一些不同选项。

方法 1:注解整个数组

可以使用 as const 断言来解决此问题。通过用 as const 注解整个数组,TypeScript 将推断 type 属性的字面量类型:

const buttonsToChange = [
  {
    type: "button",
  },
  {
    type: "submit",
  },
] as const;

将鼠标悬停在 buttonsToChange 上显示 TypeScript 已将 type 属性推断为字面量类型,并且当 buttonsToChange 传递给 modifyButtons 时,它将不再显示错误:

// 鼠标悬停在 buttonsToChange 上显示:
const buttonsToChange: readonly [
  {
    readonly type: "button";
  },
  {
    readonly type: "submit";
  },
];
方法 2:注解数组的成员

解决此问题的另一种方法是用 as const 注解数组的每个成员:

const buttonsToChange = [
  {
    type: "button",
  } as const,
  {
    type: "submit",
  } as const,
];

将鼠标悬停在 buttonsToChange 上会显示一些有趣的东西。每个对象现在都被推断为 readonly,但数组本身不是:

// 鼠标悬停在 buttonsToChange 上显示:
const buttonsToChange: (
  | {
      readonly type: "button";
    }
  | {
      readonly type: "submit";
    }
)[];

buttonsToChange 数组也不再被推断为具有固定长度的元组,因此我们可以通过向其推送新对象来修改它:

buttonsToChange.push({
  type: "button",
});

这种行为源于用 as const 标记数组的单个成员,而不是整个数组。

然而,这种推断足以满足 modifyButtons 的要求,因为它与 ButtonAttributes 类型匹配。

方法 3:在字符串上使用 as const

我们将看到的最后一个解决方案是在字符串字面量上使用 as const 来推断字面量类型:

const buttonsToChange = [
  {
    type: "button" as const,
  },
  {
    type: "submit" as const,
  },
];

现在,当我们鼠标悬停在 buttonsToChange 上时,我们丢失了 readonly 修饰符,因为 as const 仅针对对象内部的字符串,而不是对象本身:

// 鼠标悬停在 buttonsToChange 上显示:
const buttonsToChange: (
  | {
      type: "button";
    }
  | {
      type: "submit";
    }
)[];

但是,这仍然足够强类型以满足 modifyButtons 的要求。

像这样使用 as const 就像给 TypeScript 一个提示,它应该在通常不会推断字面量类型的地方推断它。当你想允许突变,但仍想推断字面量类型时,这偶尔会很有用。

想成为 TypeScript 大神吗?

解锁专业精华版

TypeScript 专业精华版

查看原文上一章:对象

下一章:类