TypeScript 的奇怪之处

TypeScript 的奇怪之处

探索 TypeScript 如何处理 'any' 类型、数组、额外属性、对象类型、枚举、类以及函数行为。

我们现在对 TypeScript 的大多数特性有了很好的理解。让我们更进一步。通过探索 TypeScript 中一些更不寻常和鲜为人知的部分,我们将更深入地理解它的工作原理。

演进的 any 类型

虽然大多数时候我们希望类型保持静态,但有时也可能创建类型可以像 JavaScript 中那样动态改变的变量。这可以通过一种称为"演进的 any"的技术来实现,该技术利用了未指定类型时变量声明和推断的方式。

首先,使用 let 声明变量而不指定类型,TypeScript 会将其推断为 any

ts 复制代码
let myVar;
     let myVar: any

现在 myVar 变量将采用赋给它的任何值的推断类型。

例如,我们可以给它赋一个数字,然后调用数字的方法,如 toExponential()。之后,我们可以将其更改为字符串并将其转换为大写:

ts 复制代码
myVar = 659457206512;

console.log(myVar.toExponential()); // 输出 "6.59457206512e+11"

myVar = "mf doom";

console.log(myVar.toUpperCase()); // 输出 "MF DOOM"

这就像一种高级的类型收窄,变量的类型根据赋给它的值而收窄。

演进的 any 数组

这种使用演进的 any 的技术也适用于数组。当你声明一个数组而没有指定特定类型时,你可以向其中推入各种类型的元素:

ts 复制代码
const evolvingArray = [];

evolvingArray.push("abc");

const elem = evolvingArray[0];
       const elem: string

evolvingArray.push(123);

const elem2 = evolvingArray[1];
       const elem2: string | number

即使没有指定类型,TypeScript 在捕捉你的行为以及你向演进的 any 类型推送的行为方面也异常智能。

额外属性警告

TypeScript 中一个令人深感困惑的部分是它如何处理对象中的额外属性。在许多情况下,当你处理对象时,TypeScript 可能不会显示你期望的错误。

让我们创建一个 Album 接口,其中包含 titlereleaseYear 属性:

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

这里我们创建一个未类型化的 rubberSoul 对象,它包含一个额外的 label 属性:

ts 复制代码
const rubberSoul = {
  title: "Rubber Soul",
  releaseYear: 1965,
  label: "Parlophone",
};

现在,如果我们创建一个接受 Album 并打印它的 processAlbum 函数,我们可以传入 rubberSoul 对象而没有任何问题:

ts 复制代码
const processAlbum = (album: Album) => console.log(album);

processAlbum(rubberSoul); // 没有错误!

这看起来很奇怪!我们期望 TypeScript 会对额外的 label 属性显示错误,但它没有。

更奇怪的是,当我们内联传递对象时,我们确实会收到错误:

ts 复制代码
processAlbum({
  title: "Rubber Soul",
  releaseYear: 1965,
  label: "Parlophone",
// 对象字面量只能指定已知的属性,但"label"不在类型"Album"中。2353
});

为什么行为不同?

变量上不进行额外属性检查

在第一个例子中,我们将专辑赋给一个变量,然后将该变量传入我们的函数。在这种情况下,TypeScript 不会检查额外属性。

原因是这个变量可能在其他地方被使用,而那些地方可能需要这个额外属性。TypeScript 不想妨碍这种用法。

但是当我们内联对象时,TypeScript 知道我们不会在其他地方使用它,所以它会检查额外属性。

这可能会让你认为 TypeScript 关心额外属性------但实际上它并不关心。它只在某些情况下检查它们。

当你拼错可选参数的名称时,这种行为可能会令人沮丧。想象一下你把 timeout 拼成了 timeOut

ts 复制代码
const myFetch = (options: { url: string; timeout?: number }) => {
  // 实现
};

const options = {
  url: "/",
  timeOut: 1000,
};

myFetch(options); // 没有错误!

在这种情况下,TypeScript 不会显示错误,你也不会得到期望的运行时行为。找出错误的唯一方法是为 options 对象提供类型注解:

ts 复制代码
const options: { timeout?: number } = {
  timeOut: 1000,
// 对象字面量只能指定已知的属性,但 'timeOut' 不在类型 '{ timeout?: number | undefined; }' 中。你是想写 'timeout' 吗?2561
};

现在,我们将一个内联对象与一个类型进行比较,TypeScript 会检查额外属性。

比较函数时不进行额外属性检查

TypeScript 不会检查额外属性的另一种情况是比较函数时。

让我们想象我们有一个 remapAlbums 函数,它本身接受一个函数:

ts 复制代码
const remapAlbums = (albums: Album[], remap: (album: Album) => Album) => {
  return albums.map(remap);
};

这个函数接受一个 Album 数组和一个重新映射每个 Album 的函数。这可以用来更改数组中每个 Album 的属性。

我们可以像这样调用它,将每个专辑的 releaseYear 增加一:

ts 复制代码
const newAlbums = remapAlbums(albums, (album) => ({
  ...album,
  releaseYear: album.releaseYear + 1,
}));

但事实证明,我们可以向函数的返回类型传递一个额外属性,而 TypeScript 不会抱怨:

ts 复制代码
const newAlbums = remapAlbums(albums, (album) => ({
  ...album,
  releaseYear: album.releaseYear + 1,
  strangeProperty: "This is strange",
}));

现在,我们的 newAlbums 数组中的每个 Album 对象都会有一个额外的 strangeProperty 属性,而 TypeScript 甚至不知道它。它认为函数的返回类型是 Album[],但实际上是 (Album & { strangeProperty: string })[]

让这个"工作"起来的方法是为我们的内联函数添加一个返回类型注解:

ts 复制代码
const newAlbums = remapAlbums(
  albums,
  (album): Album => ({
    ...album,
    releaseYear: album.releaseYear + 1,
    strangeProperty: "This is strange",
// 对象字面量只能指定已知的属性,但 'strangeProperty' 不在类型 'Album' 中。2353
  }),
);

这将导致 TypeScript 对额外的 strangeProperty 属性显示错误。

这是因为在这种情况下,我们将一个内联对象(我们返回的值)直接与一个类型进行比较。TypeScript 在这种情况下会检查额外属性。

如果没有返回类型注解,TypeScript 最终会尝试比较两个函数,并且它不太在意函数是否返回了过多的属性。

开放与封闭对象类型

默认情况下,TypeScript 将所有对象视为开放的。在任何时候,它都期望对象上可能存在其他属性。

其他语言,如 Flow,默认将对象视为封闭的。Flow 是 Meta 的内部类型系统,默认情况下要求对象是精确的(它们称之为"封闭")。

ts 复制代码
function method(obj: { foo: string }) {
  /* ... */
}

method({ foo: "test", bar: 42 }); // 错误!

你可以使用 ... 语法在 Flow 中选择开放(或非精确)对象:

ts 复制代码
function method(obj: { foo: string, ... }) {
  /* ... */
}

method({ foo: "test", bar: 42 }); // 不再有错误!

但 Flow 建议你默认使用封闭对象。他们认为,尤其是在使用展开运算符时,谨慎为妙。

为什么 TypeScript 将对象视为开放的?

开放对象更准确地反映了 JavaScript 的实际工作方式。任何针对 JavaScript 这种非常动态的语言的类型系统,都必须对其能达到的"安全"程度持相对谨慎的态度。

因此,TypeScript 默认将对象视为开放的决定,反映了它试图类型化的语言的特性。这也更接近于其他语言中对象的工作方式。

问题在于,额外属性警告常常会让你认为 TypeScript 使用封闭对象。

但实际上,额外属性警告更像是一种"礼貌性"的提醒。它仅在对象无法在其他地方被修改的情况下使用。

对象键是松散类型的

TypeScript 具有开放对象类型的一个后果是,遍历对象的键可能令人沮丧。

在 JavaScript 中,使用对象调用 Object.keys 将返回一个表示键的字符串数组。

ts 复制代码
const yetiSeason = {
  title: "Yeti Season",
  artist: "El Michels Affair",
  releaseYear: 2021,
};

const keys = Object.keys(yetiSeason);
       const keys: string[]

理论上,你可以使用这些键来访问对象的值:

ts 复制代码
keys.forEach((key) => {
  console.log(yetiSeason[key]); // key 下方有红色波浪线
// 元素隐式具有 'any' 类型,因为类型为 'string' 的表达式不能用于索引类型 '{ title: string; artist: string; releaseYear: number; }'。
// 在类型 '{ title: string; artist: string; releaseYear: number; }' 上找不到类型为 'string' 的参数的索引签名。7053
});

但是我们收到了一个错误。TypeScript 告诉我们不能使用 string 来访问 yetiSeason 的属性。

这唯一可行的方法是,如果 key 的类型是 'title' | 'artist' | 'releaseYear'。换句话说,是 keyof typeof yetiSeason。但它不是------它的类型是 string

原因在于 Object.keys ------ 它返回 string[],而不是 (keyof typeof obj)[]

ts 复制代码
const keys = Object.keys(yetiSeason);
// 找不到名称 'yetiSeason'。2304
       const keys: string[]

顺便说一句,for ... in 循环也会发生同样的行为:

ts 复制代码
for (const key in yetiSeason) {
  console.log(yetiSeason[key]);
// 元素隐式具有 'any' 类型,因为类型为 'string' 的表达式不能用于索引类型 '{ title: string; artist: string; releaseYear: number; }'。
// 在类型 '{ title: string; artist: string; releaseYear: number; }' 上找不到类型为 'string' 的参数的索引签名。7053
}

这是 TypeScript 开放对象类型的结果。TypeScript 无法在编译时知道对象的精确键,因此它必须假设每个对象上都存在未指定的键。当枚举对象的键时,它能做的最安全的事情就是将它们都视为 string

我们将在下面的练习中研究几种解决此问题的方法。

空对象类型

开放对象类型的另一个后果是空对象类型 {} 的行为可能与你预期的不同。

为了做好铺垫,让我们回顾一下类型可分配性图表:

图表顶部是 unknown 类型,它可以接受所有其他类型。底部是 never 类型,没有其他类型可以分配给它,但 never 类型本身可以分配给任何其他类型。

neverunknown 类型之间是一个类型的宇宙。空对象类型 {} 在这个宇宙中占有独特的位置。正如你可能想象的那样,它并不代表一个空对象,实际上它代表任何不是 nullundefined 的值

这意味着它可以接受许多其他类型:字符串、数字、布尔值、函数、符号以及包含属性的对象。

以下所有都是有效的赋值:

ts 复制代码
const coverArtist: {} = "Guy-Manuel De Homem-Christo";
const upcCode: {} = 724384260910;
const submit = (homework: {}) => console.log(homework);
submit("Oh Yeah");

然而,尝试使用 nullundefined 调用 submit 将导致 TypeScript 错误:

ts 复制代码
submit(null);
// 类型 'null' 的参数不能赋给类型 '{}' 的参数。2345

这可能感觉有点奇怪。但是当你记住 TypeScript 的对象是开放 的时,这就说得通了。想象一下我们的 success 函数实际上接受一个包含 message 的对象。如果我们给它传递一个额外属性,TypeScript 会很高兴:

ts 复制代码
const success = (response: { message: string }) =>
  console.log(response.message);

const messageWithExtra = { message: "Success!", extra: "This is extra" };

success(messageWithExtra); // 没有错误!

空对象实际上是"最开放"的对象。字符串、数字、布尔值在 JavaScript 中都可以被认为是对象。它们各自都有属性和方法。所以 TypeScript 很高兴将它们分配给空对象类型。

JavaScript 中唯一没有属性的是 nullundefined。试图访问这两者之一的属性将导致运行时错误。所以,它们不符合 TypeScript 中对象的定义。

当你考虑到这一点时,空对象类型 {} 是一个相当优雅的解决方案,用于表示任何不是 nullundefined 的东西。

类型世界和值世界

在大多数情况下,TypeScript 可以分为两个语法空间:类型世界和值世界。这两个世界可以并存于同一行代码中:

ts 复制代码
const myNumber: number = 42;
//    ^^^^^^^^  ^^^^^^   ^^
//    值        类型     值

这可能会令人困惑,特别是因为 TypeScript 喜欢在两个世界中重用相同的关键字:

ts 复制代码
if (typeof key === "string" && (key as keyof typeof obj)) {
  //^^^^^^^^^^^^^^^^^^^^^^          ^^^^^^^^^^^^^^^^^^^
  //值                               类型
}

但是 TypeScript 非常严格地对待这个边界。例如,你不能在值世界中使用类型:

ts 复制代码
type Album = {
  title: string;
  artist: string;
};

processAlbum(Album);
// 'Album' 只表示一个类型,但在这里被用作一个值。2693

如你所见,Album 甚至不存在于值世界中,所以当我们尝试将其用作值时,TypeScript 会显示错误。

另一个常见的例子是尝试将值直接传递给类型:

ts 复制代码
type Album = ReturnType<processAlbum>;
// 'processAlbum' 指向一个值,但在这里被用作一个类型。你是想用 'typeof processAlbum' 吗?2749

在这种情况下,TypeScript 建议使用 typeof processAlbum 而不是 processAlbum 来修复错误。

这些边界非常清晰------除了一些情况。某些实体可以同时存在于类型世界和值世界。

考虑这个 Song 类,它使用了在构造函数中声明属性的快捷方式:

ts 复制代码
class Song {
  title: string;
  artist: string;
  constructor(title: string, artist: string) {
    this.title = title;
    this.artist = artist;
  }
}

我们可以使用 Song 类作为类型,例如用于类型化函数参数:

ts 复制代码
const playSong = (song: Song) =>
  console.log(`Playing ${song.title} by ${song.artist}`);

此类型指的是 Song 类的实例,而不是类本身:

ts 复制代码
const song1 = new Song("Song 1", "Artist 1");
playSong(song1);

playSong(Song);
// 类型 'typeof Song' 的参数不能赋给类型 'Song' 的参数。
//   类型 'typeof Song' 从类型 'Song' 中缺少以下属性:title, artist2345

在这种情况下,当我们尝试将 Song 类本身传递给 playSong 函数时,TypeScript 会显示错误。这是因为 Song 是一个类,而不是该类的实例。

所以,类同时存在于类型世界和值世界,并且在用作类型时表示类的实例。

枚举

枚举也可以跨越世界。

考虑这个 AlbumStatus 枚举,以及一个确定是否有折扣可用的函数:

ts 复制代码
enum AlbumStatus {
  NewRelease = 0,
  OnSale = 1,
  StaffPick = 2,
  Clearance = 3,
}

function logAlbumStatus(status: AlbumStatus) {
  if (status === AlbumStatus.NewRelease) {
    console.log("没有可用折扣。");
  } else {
    console.log("有折扣价可用。");
  }
}

你可以使用 typeof AlbumStatus 来引用枚举本身的整个结构:

ts 复制代码
function logAlbumStatus(status: typeof AlbumStatus) {
  // ...实现
}

但是那样你就需要向函数传递一个匹配枚举的结构:

ts 复制代码
logAlbumStatus({
  NewRelease: 0,
  OnSale: 1,
  StaffPick: 2,
  Clearance: 3,
});

当用作类型时,枚举指的是枚举的成员,而不是整个枚举本身。

this 关键字

this 关键字也可以跨越类型世界和值世界。

为了说明这一点,我们将使用这个 Song 类,它的实现与我们之前看到的略有不同:

ts 复制代码
class Song {
  playCount: number;
  constructor(title: string) {
    this.playCount = 0;
  }

  play(): this {
    this.playCount += 1;
    return this;
  }
}

play 方法内部,this.playCount 使用 this 作为值来访问 this.playCount 属性,同时也作为类型来指定方法的返回值类型。

play 方法返回 this 时,在类型世界中,它表示该方法返回当前类的一个实例。

这意味着我们可以创建一个新的 Song 实例并链式调用 play 方法多次:

ts 复制代码
const earworm = new Song("Mambo No. 5", "Lou Bega").play().play().play();

this 是一个罕见的情况,其中 thistypeof this 是相同的东西。我们可以用 typeof this 替换 this 返回类型,代码仍然会以相同的方式工作:

ts 复制代码
class Song {
  // ...实现
  play(): typeof this {
    this.playCount += 1;
    return this;
  }
}

两者都指向类的当前实例。

类型和值同名

最后,可以将类型和值命名为相同的东西。当你想将类型用作值,或将值用作类型时,这可能很有用。

考虑这个 Track 对象,它已创建为一个常量,并注意大写的"T":

ts 复制代码
export const Track = {
  play: (title: string) => {
    console.log(`正在播放: ${title}`);
  },
  pause: () => {
    console.log("歌曲已暂停");
  },
  stop: () => {
    console.log("歌曲已停止");
  },
};

接下来,我们将创建一个 Track 类型,它镜像 Track 常量:

ts 复制代码
export type Track = typeof Track;

我们现在有两个以相同名称导出的实体:一个是值,另一个是类型。这使得 Track 在我们使用它时可以同时充当两者。

假设我们在另一个文件中,我们可以导入 Track 并在一个只播放"Mambo No. 5"的函数中使用它:

ts 复制代码
import { Track } from "./other-file";

const mamboNumberFivePlayer = (track: Track) => {
  track.play("Mambo No. 5");
};

mamboNumberFivePlayer(Track);

在这里,我们使用 Track 作为类型来类型化 track 参数,并作为值传递给 mamboNumberFivePlayer 函数。

将鼠标悬停在 Track 上会显示它既是类型也是值:

ts 复制代码
// 鼠标悬停在 { Track } 上显示:
(alias) type Track = {
  play: (title: string) => void;
  pause: () => void;
  stop: () => void;
}
(alias) const Track = {
  play: (title: string) => void;
  pause: () => void;
  stop: () => void;
}

正如我们所见,TypeScript 已将 Track 别名化为类型和值。这意味着它在两个世界中都可用。

一个简单的例子是断言 Track as Track

ts 复制代码
console.log(Track as Track);
//          ^^^^^    ^^^^^
//          值        类型

TypeScript 可以无缝地在两者之间切换,当你想将类型重用为值,或将值重用为类型时,这可能非常有用。

这种双重功能非常有用,特别是当你有一些感觉像是类型的东西,而你又想在代码的其他地方重用它们时。

函数中的 this

我们已经看到了如何在类中使用 this 来引用类的当前实例。但是 this 也可以在函数和对象中使用。

函数中的 this

这里我们有一个代表专辑的对象,它包含一个用 function 关键字编写的 sellAlbum 函数:

ts 复制代码
const solidAir = {
  title: "Solid Air",
  artist: "John Martyn",
  sales: 40000,
  price: 12.99,
  sellAlbum: function () {
    this.sales++;
    console.log(`${this.title} 已售出 ${this.sales} 份。`);
  },
};

请注意,在 sellAlbum 函数内部,this 用于访问 album 对象的 salestitle 属性。

当我们调用 sellAlbum 函数时,它会增加 sales 属性并打印预期的消息:

ts 复制代码
album.sellAlbum(); // 输出 "Solid Air 已售出 40001 份。"

这是因为当使用 function 关键字声明函数时,this 将始终引用该函数所属的对象。即使函数实现在对象外部编写,当调用该函数时,this 仍将引用该对象:

ts 复制代码
function sellAlbum() {
  this.sales++;
  console.log(`${this.title} 已售出 ${this.sales} 份。`);
}

const album = {
  title: "Solid Air",
  artist: "John Martyn",
  sales: 40000,
  price: 12.99,
  sellAlbum,
};

虽然 sellAlbum 函数可以工作,但目前 this.titlethis.sales 属性的类型是 any。所以我们需要找到一种方法来在函数中类型化 this

幸运的是,我们可以在函数签名中将 this 类型化为一个参数:

ts 复制代码
function sellAlbum(this: { title: string; sales: number }) {
  this.sales++;
  console.log(`${this.title} 已售出 ${this.sales} 份。`);
}

请注意,this 不是在调用函数时需要传入的参数。它只是引用该函数所属的对象。

现在,我们可以将 sellAlbum 函数传递给 album 对象:

ts 复制代码
const album = {
  sellAlbum,
};

这里的类型检查方式有点奇怪------它不是立即检查 this,而是在调用函数时检查它:

ts 复制代码
album.sellAlbum();
// '{ sellAlbum: (this: { title: string; sales: number; }) => void; }' 类型的 'this' 上下文不可分配给方法的 '{ title: string; sales: number; }' 类型的 'this'。
//   类型 '{ sellAlbum: (this: { title: string; sales: number; }) => void; }' 缺少类型 '{ title: string; sales: number; }' 中的以下属性:title, sales2684

我们可以通过向 album 对象添加 titlesales 属性来解决此问题:

ts 复制代码
const album = {
  title: "Solid Air",
  sales: 40000,
  sellAlbum,
};

现在当我们调用 sellAlbum 函数时,TypeScript 将知道 this 指向一个具有 string 类型 title 属性和 number 类型 sales 属性的对象。

箭头函数

箭头函数与使用 function 关键字的函数不同,不能用 this 参数进行注解:

ts 复制代码
const sellAlbum = (this: { title: string; sales: number }) => {
// 箭头函数不能有 'this' 参数。2730
  // 实现
};

这是因为箭头函数不能从它们被调用的作用域继承 this。相反,它们从它们被定义 的作用域继承 this。这意味着它们只能在类内部定义时访问 this

函数可赋值性

让我们更深入地探讨 TypeScript 中函数是如何比较的。

比较函数参数

在检查一个函数是否可分配给另一个函数时,并非所有函数参数都需要实现。这可能有点令人惊讶。

假设我们正在构建一个 handlePlayer 函数。此函数侦听音乐播放器并在发生某些事件时调用用户定义的回调。它应该能够接受一个回调,该回调具有单个 filename 参数:

ts 复制代码
handlePlayer((filename: string) => console.log(`正在播放 ${filename}`));

它还应该处理一个带有 filenamevolume 的回调:

ts 复制代码
handlePlayer((filename: string, volume: number) =>
  console.log(`正在以音量 ${volume} 播放 ${filename}`),
);

最后,它应该能够处理一个带有 filenamevolumebassBoost 的回调:

ts 复制代码
handlePlayer((filename: string, volume: number, bassBoost: boolean) => {
  console.log(`正在以音量 ${volume} 播放 ${filename},低音增强已开启!`);
});

CallbackType 类型化为三种不同函数类型的联合类型可能很诱人:

ts 复制代码
type CallbackType =
  | ((filename: string) => void)
  | ((filename: string, volume: number) => void)
  | ((filename: string, volume: number, bassBoost: boolean) => void);

const handlePlayer = (callback: CallbackType) => {
  // 实现
};

然而,当使用单个和双参数回调调用 handlePlayer 时,这会导致隐式的 any 错误:

ts 复制代码
handlePlayer((filename) => console.log(`正在播放 ${filename}`));
// 参数 'filename' 隐式具有 'any' 类型。7006

handlePlayer((filename, volume) =>
// 参数 'volume' 隐式具有 'any' 类型。7006参数 'filename' 隐式具有 'any' 类型。7006
  console.log(`正在以音量 ${volume} 播放 ${filename}`),
);

handlePlayer((filename, volume, bassBoost) => {
  console.log(`正在以音量 ${volume} 播放 ${filename},低音增强已开启!`);
}); // 没有错误

这个函数联合显然不起作用。有一个更简单的解决方案。

你实际上可以移除联合的前两个成员,只包含具有所有三个参数的成员:

ts 复制代码
type CallbackType = (
  filename: string,
  volume: number,
  bassBoost: boolean,
) => void;

一旦做出此更改,其他两个回调版本的隐式 any 错误将消失。

ts 复制代码
handlePlayer((filename) => console.log(`正在播放 ${filename}`)); // 没有错误

handlePlayer((filename, volume) =>
  console.log(`正在以音量 ${volume} 播放 ${filename}`),
); // 没有错误

这起初可能看起来很奇怪------这些函数难道不是规范不足吗?

让我们分解一下。传递给 handlePlayer 的回调将使用三个参数调用。如果回调只接受一个或两个参数,这没问题!回调忽略参数不会导致任何运行时错误。

如果回调接受的参数多于传递的参数,TypeScript 会显示错误:

ts 复制代码
handlePlayer((filename, volume, bassBoost, extra) => {
// 参数 'extra' 隐式具有 'any' 类型。7006参数 'bassBoost' 隐式具有 'any' 类型。7006参数 'volume' 隐式具有 'any' 类型。7006参数 'filename' 隐式具有 'any' 类型。7006参数类型 '(filename: any, volume: any, bassBoost: any, extra: any) => void' 不可分配给参数类型 'CallbackType'。
//   目标签名提供的参数太少。期望 4 个或更多,但得到 3 个。2345
  console.log(`正在以音量 ${volume} 播放 ${filename},低音增强已开启!`);
});

由于 extra 永远不会传递给回调,TypeScript 会显示错误。

但是,再次强调,实现比预期少的参数是可以的。为了进一步说明,我们可以在数组上调用 map 时看到这个概念的实际应用:

ts 复制代码
["macarena.mp3", "scatman.wma", "cotton-eye-joe.ogg"].map((file) =>
  file.toUpperCase(),
);

.map 总是用三个参数调用:当前元素、索引和完整数组。但我们不必全部使用它们。在这种情况下,我们只关心 file 参数。

所以,仅仅因为一个函数可以接收一定数量的参数,并不意味着它必须在其实现中全部使用它们。

函数的联合类型

当创建函数的联合类型时,TypeScript 会做一些可能出乎意料的事情。它会创建参数的交集。

考虑这个 formatterFunctions 对象:

ts 复制代码
const formatterFunctions = {
  title: (album: { title: string }) => `标题: ${album.title}`,
  artist: (album: { artist: string }) => `艺术家: ${album.artist}`,
  releaseYear: (album: { releaseYear: number }) =>
    `发行年份: ${album.releaseYear}`,
};

formatterFunctions 对象中的每个函数都接受一个具有特定属性的 album 对象,并返回一个字符串。

现在,让我们创建一个 getAlbumInfo 函数,它接受一个 album 对象和一个 key,该 key 将用于从 formatterFunctions 对象中调用相应的函数:

ts 复制代码
const getAlbumInfo = (album: any, key: keyof typeof formatterFunctions) => {
  const functionToCall = formatterFunctions[key];
  return functionToCall(album);
};

我们现在将 album 注解为 any,但让我们花点时间思考一下:它应该用什么注解?

我们可以通过将鼠标悬停在 functionToCall 上来获得线索:

ts 复制代码
// 鼠标悬停在 functionToCall 上显示:
const functionToCall:
  | ((album: { title: string }) => string)
  | ((album: { artist: string }) => string)
  | ((album: { releaseYear: number }) => string);

functionToCall 被推断为 formatterFunctions 对象中三个不同函数的联合类型。

当然,这意味着我们应该用三种不同类型的 album 对象的联合类型来调用它,对吗?

ts 复制代码
const getAlbumInfo = (
  album: { title: string } | { artist: string } | { releaseYear: number },
  key: keyof typeof formatterFunctions,
) => {
  const functionToCall = formatterFunctions[key];
  return functionToCall(album);
// 类型 '{ title: string; } | { artist: string; } | { releaseYear: number; }' 的参数不可分配给类型 '{ title: string; } & { artist: string; } & { releaseYear: number; }' 的参数。
//   类型 '{ title: string; }' 不可分配给类型 '{ title: string; } & { artist: string; } & { releaseYear: number; }'。
//     属性 'artist' 在类型 '{ title: string; }' 中缺失,但在类型 '{ artist: string; }' 中是必需的。2345
};

我们可以从错误中看出我们错在哪里了。functionToCall 实际上需要用这三种不同类型的 album 对象的交集来调用,而不是它们的联合。

这很有道理。为了满足每个函数,我们需要提供一个同时具有 titleartistreleaseYear 这三个属性的对象。如果我们漏掉其中一个属性,我们将无法满足其中一个函数。

所以,我们可以提供一个类型,它是三种不同类型 album 对象的交集:

ts 复制代码
const getAlbumInfo = (
  album: { title: string } & { artist: string } & { releaseYear: number },
  key: keyof typeof formatterFunctions,
) => {
  const functionToCall = formatterFunctions[key];
  return functionToCall(album);
};

这本身可以简化为单个对象类型:

ts 复制代码
const getAlbumInfo = (
  album: { title: string; artist: string; releaseYear: number },
  key: keyof typeof formatterFunctions,
) => {
  const functionToCall = formatterFunctions[key];
  return functionToCall(album);
};

现在,当我们调用 getAlbumInfo 时,TypeScript 会知道 album 是一个具有 titleartistreleaseYear 属性的对象。

ts 复制代码
const formatted = getAlbumInfo(
  {
    title: "Solid Air",
    artist: "John Martyn",
    releaseYear: 1973,
  },
  "title",
);

这种情况相对容易解决,因为每个参数都与其他参数兼容。但是当处理不兼容的参数时,事情可能会变得有点复杂。我们将在练习中进一步研究这一点。

练习

练习 1:接受除 nullundefined 之外的任何值

这里我们有一个函数 acceptAnythingExceptNullOrUndefined,它还没有被分配类型注解:

ts 复制代码
const acceptAnythingExceptNullOrUndefined = (input) => {};
// 参数 'input' 隐式具有 'any' 类型。7006

此函数可以使用各种输入进行调用:字符串、数字、布尔表达式、符号、对象、数组、函数、正则表达式和 Error 类实例:

ts 复制代码
acceptAnythingExceptNullOrUndefined("hello");
acceptAnythingExceptNullOrUndefined(42);
acceptAnythingExceptNullOrUndefined(true);
acceptAnythingExceptNullOrUndefined(Symbol("foo"));
acceptAnythingExceptNullOrUndefined({});
acceptAnythingExceptNullOrUndefined([]);
acceptAnythingExceptNullOrUndefined(() => {});
acceptAnythingExceptNullOrUndefined(/foo/);
acceptAnythingExceptNullOrUndefined(new Error("foo"));

这些输入都不应引发错误。

然而,正如函数名称所示,如果我们向函数传递 nullundefined,我们希望它引发错误。

ts 复制代码
acceptAnythingExceptNullOrUndefined(
  // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
  null,
);

acceptAnythingExceptNullOrUndefined(
  // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
  undefined,
);

你的任务是向 acceptAnythingExceptNullOrUndefined 函数添加一个类型注解,使其能够接受除 nullundefined 之外的任何值。

练习 2:检测对象中的额外属性

在这个练习中,我们处理一个 options 对象以及一个 FetchOptions 接口,该接口指定了 urlmethodheadersbody

ts 复制代码
interface FetchOptions {
  url: string;
  method?: string;
  headers?: Record<string, string>;
  body?: string;
}

const options = {
  url: "/",
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
  search: new URLSearchParams({
    limit: "10",
  }),
};

请注意,options 对象有一个额外的属性 search,它没有在 FetchOptions 接口中指定,同时还有一个当前不起作用的 @ts-expect-error 指令。

还有一个 myFetch 函数,它接受一个 FetchOptions 类型的对象作为其参数,当使用 options 对象调用时没有任何错误:

ts 复制代码
const myFetch = async (options: FetchOptions) => {};

myFetch(options);

你的挑战是确定为什么 @ts-expect-error 指令不起作用,并重构代码使其起作用。尝试用多种方法解决它!

练习 3:检测函数中的额外属性

这是另一个 TypeScript 没有在我们期望的地方触发额外属性警告的练习。

这里我们有一个 User 接口,具有 idname 属性,以及一个包含两个用户对象 "Waqas" 和 "Zain" 的 users 数组。

ts 复制代码
interface User {
  id: number;
  name: string;
}

const users = [
  {
    name: "Waqas",
  },
  {
    name: "Zain",
  },
];

一个 usersWithIds 变量被类型化为一个 User 数组。一个 map() 函数用于将用户展开到一个新创建的对象中,该对象具有 id 和一个 age 为 30 的属性:

ts 复制代码
const usersWithIds: User[] = users.map((user, index) => ({
  ...user,
  id: index,
  // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
  age: 30,
}));

尽管 TypeScript 不期望 User 上有 age 属性,但它没有显示错误,并且在运行时该对象确实会包含 age 属性。

你的任务是确定为什么 TypeScript 在这种情况下没有引发错误,并找到两种不同的解决方案,使其在添加意外属性时能适当地报错。

练习 4:遍历对象

考虑一个具有 idname 属性的 User 接口,以及一个接受 User 作为其参数的 printUser 函数:

ts 复制代码
interface User {
  id: number;
  name: string;
}

function printUser(user: User) {}

在测试设置中,我们希望使用 id1name 为 "Waqas" 来调用 printUser 函数。期望是 console.log 上的侦听器(spy)首先被调用时参数为 1,然后是 "Waqas":

ts 复制代码
it("应该打印用户的所有键", () => {
  const consoleSpy = vitest.spyOn(console, "log");

  printUser({
    id: 1,
    name: "Waqas",
  });

  expect(consoleSpy).toHaveBeenCalledWith(1);
  expect(consoleSpy).toHaveBeenCalledWith("Waqas");
});

你的任务是实现 printUser 函数,使测试用例按预期通过。

显然,你可以在 printUser 函数内部手动打印属性,但这里的目标是遍历对象的每个属性。

尝试用 for 循环作为一种解决方案,用 Object.keys().forEach() 作为另一种解决方案。作为额外挑战,尝试将函数参数的类型扩展到 User 之外,作为第三种解决方案。

记住,Object.keys() 的类型是始终返回一个字符串数组。

练习 5:函数参数比较

这里我们有一个 listenToEvent 函数,它接受一个回调,该回调可以根据调用方式处理不同数量的参数。目前 CallbackType 设置为 unknown

ts 复制代码
type Event = "click" | "hover" | "scroll";
type CallbackType = unknown;
const listenToEvent = (callback: CallbackType) => {};

例如,我们可能想调用 listenToEvent 并传递一个不接受任何参数的函数------在这种情况下,根本不需要担心参数:

ts 复制代码
listenToEvent(() => {});

或者,我们可以传递一个期望单个参数 event 的函数:

ts 复制代码
listenToEvent((event) => {
// 参数 'event' 隐式具有 'any' 类型。7006
  type tests = [Expect<Equal<typeof event, Event>>];
// 类型 'false' 不满足约束 'true'。2344
});

更进一步,我们可以用 eventxy 来调用它:

ts 复制代码
listenToEvent((event, x, y) => {
// 参数 'y' 隐式具有 'any' 类型。7006参数 'x' 隐式具有 'any' 类型。7006参数 'event' 隐式具有 'any' 类型。7006
  // event, x, y 下方有红色波浪线
  type tests = [
    Expect<Equal<typeof event, Event>>, // 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof x, number>>,    // 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof y, number>>,    // 类型 'false' 不满足约束 'true'。2344
  ];
});

最后,该函数可以接受参数 eventxyscreenID

ts 复制代码
listenToEvent((event, x, y, screenId) => {
// 参数 'screenId' 隐式具有 'any' 类型。7006参数 'y' 隐式具有 'any' 类型。7006参数 'x' 隐式具有 'any' 类型。7006参数 'event' 隐式具有 'any' 类型。7006
  // event, x, y, 和 screenId 下方有红色波浪线
  type tests = [
    Expect<Equal<typeof event, Event>>,       // 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof x, number>>,         // 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof y, number>>,         // 类型 'false' 不满足约束 'true'。2344
    Expect<Equal<typeof screenId, number>>, // 类型 'false' 不满足约束 'true'。2344
  ];
});

在几乎所有情况下,TypeScript 都会给我们报错。

你的任务是更新 CallbackType 以确保它可以处理所有这些情况。

练习 6:带有对象参数的函数联合类型

这里我们处理两个函数:logIdlogNamelogId 函数将对象中的 id 打印到控制台,而 logNamename 执行相同的操作:

这些函数被组合到一个名为 loggers 的数组中:

ts 复制代码
const logId = (obj: { id: string }) => {
  console.log(obj.id);
};

const logName = (obj: { name: string }) => {
  console.log(obj.name);
};

const loggers = [logId, logName];

logAll 函数内部,一个当前未类型化的对象作为参数传递。然后使用此对象调用 loggers 数组中的每个记录器函数:

ts 复制代码
const logAll = (obj) => {
// 参数 'obj' 隐式具有 'any' 类型。7006
  // obj 下方有红色波浪线
  loggers.forEach((func) => func(obj));
};

你的任务是确定如何为 logAll 函数的 obj 参数指定类型。仔细查看各个记录器函数的类型签名,以了解此对象应该是什么类型。

练习 7:具有不兼容参数的函数联合类型

这里我们正在处理一个名为 objOfFunctions 的对象,它包含以 stringnumberboolean 为键的函数。每个键都有一个关联的函数来处理该类型的输入:

ts 复制代码
const objOfFunctions = {
  string: (input: string) => input.toUpperCase(),
  number: (input: number) => input.toFixed(2),
  boolean: (input: boolean) => (input ? "true" : "false"),
};

format 函数接受一个可以是 stringnumberboolean 的输入。它通过常规的 typeof 运算符从此输入中提取类型,但它将该运算符断言为 stringnumberboolean

它看起来是这样的:

ts 复制代码
const format = (input: string | number | boolean) => {
  // 'typeof' 不够智能,无法知道
  // 它只能是 'string'、'number' 或 'boolean',
  // 所以我们需要使用 'as'
  const inputType = typeof input as "string" | "number" | "boolean";
  const formatter = objOfFunctions[inputType];
  return formatter(input);
// 类型 'string | number | boolean' 的参数不可分配给类型 'never' 的参数。
//   类型 'string' 不可分配给类型 'never'。2345
};

objOfFunctions 中提取的 formatter 最终被类型化为函数的联合类型。这是因为它可能是接受 stringnumberboolean 的任何一个函数:

ts 复制代码
// 鼠标悬停在 formatter 上显示:
const formatter:
  | ((input: string) => string)
  | ((input: number) => string)
  | ((input: boolean) => "true" | "false");

目前 format 函数的返回语句中的 input 有一个错误。你的挑战是在类型级别解决此错误,即使代码在运行时可以工作。尝试使用断言作为一种解决方案,使用类型守卫作为另一种解决方案。

一个有用的提示------any 不能分配给 never

解决方案 1:接受除 nullundefined 之外的任何值

解决方案是向 input 参数添加一个空对象注解:

ts 复制代码
const acceptAnythingExceptNullOrUndefined = (input: {}) => {};

由于 input 参数被类型化为空对象,它将接受除 nullundefined 之外的任何值。

解决方案 2:检测对象中的额外属性

在练习的起点我们没有看到错误,因为 TypeScript 的对象是开放的,而不是封闭的。options 对象具有 FetchOptions 接口的所有必需属性,因此 TypeScript 认为它可以分配给 FetchOptions,并且不关心是否添加了其他属性。

让我们看看几种使额外属性错误按预期工作的方法:

选项 1:添加类型注解

options 对象添加类型注解将导致额外属性的错误:

ts 复制代码
const options: FetchOptions = {
  url: "/",
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  // @ts-expect-error
  search: new URLSearchParams({
    limit: "10",
  }),
};

这会触发额外属性错误,因为 TypeScript 直接将对象字面量与类型进行比较。

选项 2:使用 satisfies 关键字

触发额外属性检查的另一种方法是在变量声明的末尾添加 satisfies 关键字:

ts 复制代码
const options = {
  url: "/",
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  // @ts-expect-error
  search: new URLSearchParams({
    limit: "10",
  }),
} satisfies FetchOptions;

这出于相同的原因起作用。

选项 3:内联变量

最后,如果变量内联到函数调用中,TypeScript 也会检查额外属性:

ts 复制代码
const myFetch = async (options: FetchOptions) => {};

myFetch({
  url: "/",
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  // @ts-expect-error
  search: new URLSearchParams({
    limit: "10",
  }),
});

在这种情况下,TypeScript 会提供一个错误,因为它知道 search 不是 FetchOptions 接口的一部分。

开放对象 оказалось более полезным, чем может показаться на первый взгляд. Если бы проверка лишних свойств выполнялась постоянно, как в случае с Flow, это могло бы доставить неудобства, так как вам пришлось бы вручную удалять search перед передачей его в fetch.

解决方案 3:检测函数中的额外属性

对于这个练习,我们将研究两种解决方案。

选项 1:给映射函数一个返回类型

解决此问题的第一个方法是注解 map 函数。

在这种情况下,映射函数将接收一个 user(一个带有 name 字符串的对象)和一个 index(一个数字)。

然后对于返回类型,我们将指定它必须返回一个 User 对象。

ts 复制代码
const usersWithIds: User[] = users.map(
  (user, index): User => ({
    ...user,
    id: index,
    // @ts-expect-error
    age: 30,
  }),
);

通过此设置,age 上会出现错误,因为它不是 User 类型的一部分。

选项 2:使用 satisfies

对于此解决方案,我们将使用 satisfies 关键字来确保从 map 函数返回的对象满足 User 类型:

ts 复制代码
const usersWithIds: User[] = users.map(
  (user, index) =>
    ({
      ...user,
      id: index,
      // @ts-expect-error
      age: 30,
    } satisfies User),
);

TypeScript 的额外属性检查有时会导致意外行为,尤其是在处理函数返回值时。为避免此问题,请始终为可能包含额外属性的变量声明类型,或在函数中指明预期的返回类型。

解决方案 4:遍历对象

让我们看看 printUser 函数的两种循环方法。

选项 1:使用 Object.keys().forEach()

第一种方法是使用 Object.keys().forEach() 遍历对象键。在 forEach 回调内部,我们将使用 key 变量访问键的值:

ts 复制代码
function printUser(user: User) {
  Object.keys(user).forEach((key) => {
    console.log(user[key]);
// 元素隐式具有 'any' 类型,因为类型为 'string' 的表达式不能用于索引类型 'User'。
//   在类型 'User' 上找不到类型为 'string' 的参数的索引签名。7053
  });
}

此更改将使测试用例通过,但 TypeScript 会在 user[key] 上引发类型错误。

问题在于 User 接口没有索引签名。为了在不修改 User 接口的情况下绕过类型错误,我们可以对 key 使用类型断言,告诉 TypeScript 它的类型是 keyof User

ts 复制代码
console.log(user[key as keyof User]);

keyof User 将是属性名称的联合类型,例如 idname。通过使用 as,我们告诉 TypeScript key 像一个更精确的字符串。

通过此更改,错误消失了------但我们的代码安全性降低了一些。如果我们的对象有一个意外的键,我们可能会得到一些奇怪的行为。

选项 2:使用 for 循环

for 循环方法类似于 Object.keys().forEach() 方法。我们可以使用 for 循环并传入一个对象而不是 user

ts 复制代码
function printUser(user: User) {
  for (const key in user) {
    console.log(user[key as keyof typeof user]);
  }
}

和以前一样,由于 TypeScript 处理额外属性检查的方式,我们需要使用 keyof typeof

选项 3:扩展类型

另一种方法是在 printUser 函数内部扩展类型。在这种情况下,我们将指定传入的 user 是一个具有 string 键和 unknown 值的 Record

在这种情况下,传入的对象不必是 user,因为我们将遍历它接收到的每个键:

ts 复制代码
function printUser(obj: Record<string, unknown>) {
  Object.keys(obj).forEach((key) => {
    console.log(obj[key]);
  });
}

这在运行时和类型级别都有效,没有错误。

选项 4:Object.values

另一种遍历对象的方法是使用 Object.values

ts 复制代码
function printUser(user: User) {
  Object.values(user).forEach(console.log);
}

这种方法避免了键的整个问题,因为 Object.values 将返回对象值的数组。当此选项可用时,这是避免处理松散类型键问题的好方法。

在遍历对象键时,处理此问题主要有两种选择:你可以通过 as keyof typeof 使键访问略微不安全,或者你可以使被索引的类型更宽松。两种方法都有效,因此由你决定哪种方法最适合你的用例。

解决方案 5:函数参数比较

解决方案是将 CallbackType 类型化为一个指定了所有可能参数的函数:

ts 复制代码
type CallbackType = (
  event: Event,
  x: number,
  y: number,
  screenId: number,
) => void;

回想一下,在实现一个函数时,它不必关注传入的每个参数。但是,它不能使用其定义中不存在的参数。

通过将 CallbackType 类型化为包含所有可能的参数,无论传入多少参数,测试用例都将通过。

解决方案 6:带有对象参数的函数联合类型

将鼠标悬停在 loggers.forEach() 上,我们可以看到 func 是两种不同类型函数的联合:

ts 复制代码
const logAll = (obj) => {
  loggers.forEach((func) => func(obj));
};

// 鼠标悬停在 forEach 上显示:
(parameter) func: ((obj: {
  id: string;
}) => void) | ((obj: {
  name: string;
}) => void)

一个函数接收一个 id 字符串,另一个接收一个 name 字符串。

这是有道理的,因为当我们调用数组时,我们不知道在哪个时间点得到哪一个。

我们可以对 idname 使用带有对象的交集类型:

ts 复制代码
const logAll = (obj: { id: string } & { name: string }) => {
  loggers.forEach((func) => func(obj));
};

或者,我们可以只传入一个带有 id 字符串和 name 字符串属性的常规对象。正如我们所见,拥有一个额外的属性不会导致运行时问题,TypeScript 也不会抱怨它:

ts 复制代码
const logAll = (obj: { id: string; name: string }) => {
  loggers.forEach((func) => func(obj));
};

在这两种情况下,结果都是 func 成为一个包含所有可能传入项的函数:

ts 复制代码
// 鼠标悬停在 func 上显示:
(parameter) func: (obj: {
    id: string;
} & {
    name: string;
}) => void

这种行为是合理的,并且这种模式在处理具有不同需求的函数时非常有用。

解决方案 7:具有不兼容参数的函数联合类型

将鼠标悬停在 formatter 函数上,我们看到它的 input 类型为 never,因为它是类型不兼容的联合:

ts 复制代码
// 鼠标悬停在 formatter 上显示:
const formatter: (input: never) => string;

为了修复类型级别的问题,我们可以使用 as never 断言来告诉 TypeScript input 的类型是 never

ts 复制代码
// 在 format 函数内部
return formatter(input as never);

这有点不安全,但我们从运行时行为知道 input 将始终是 stringnumberboolean

有趣的是,as any 在这里行不通,因为 any 不能分配给 never

ts 复制代码
const format = (input: string | number | boolean) => {
  const inputType = typeof input as "string" | "number" | "boolean";
  const formatter = objOfFunctions[inputType];
  return formatter(input as any);
// 类型 'any' 的参数不可分配给类型 'never' 的参数。2345
};

解决此问题的另一种方法是放弃我们的函数联合,方法是在调用 formatter 之前缩小 input 的类型:

ts 复制代码
const format = (input: string | number | boolean) => {
  if (typeof input === "string") {
    return objOfFunctions.string(input);
  } else if (typeof input === "number") {
    return objOfFunctions.number(input);
  } else {
    return objOfFunctions.boolean(input);
  }
};

这个解决方案更冗长,编译效果不如 as never 好,但它会按预期修复错误。

相关推荐
jonjia2 小时前
类型派生
typescript
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