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 接口,其中包含 title 和 releaseYear 属性:
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 类型本身可以分配给任何其他类型。
在 never 和 unknown 类型之间是一个类型的宇宙。空对象类型 {} 在这个宇宙中占有独特的位置。正如你可能想象的那样,它并不代表一个空对象,实际上它代表任何不是 null 或 undefined 的值。
这意味着它可以接受许多其他类型:字符串、数字、布尔值、函数、符号以及包含属性的对象。
以下所有都是有效的赋值:
ts
const coverArtist: {} = "Guy-Manuel De Homem-Christo";
const upcCode: {} = 724384260910;
const submit = (homework: {}) => console.log(homework);
submit("Oh Yeah");
然而,尝试使用 null 或 undefined 调用 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 中唯一没有属性的是 null 和 undefined。试图访问这两者之一的属性将导致运行时错误。所以,它们不符合 TypeScript 中对象的定义。
当你考虑到这一点时,空对象类型 {} 是一个相当优雅的解决方案,用于表示任何不是 null 或 undefined 的东西。
类型世界和值世界
在大多数情况下,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 是一个罕见的情况,其中 this 和 typeof 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 对象的 sales 和 title 属性。
当我们调用 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.title 和 this.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 对象添加 title 和 sales 属性来解决此问题:
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}`));
它还应该处理一个带有 filename 和 volume 的回调:
ts
handlePlayer((filename: string, volume: number) =>
console.log(`正在以音量 ${volume} 播放 ${filename}`),
);
最后,它应该能够处理一个带有 filename、volume 和 bassBoost 的回调:
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 对象的交集来调用,而不是它们的联合。
这很有道理。为了满足每个函数,我们需要提供一个同时具有 title、artist 和 releaseYear 这三个属性的对象。如果我们漏掉其中一个属性,我们将无法满足其中一个函数。
所以,我们可以提供一个类型,它是三种不同类型 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 是一个具有 title、artist 和 releaseYear 属性的对象。
ts
const formatted = getAlbumInfo(
{
title: "Solid Air",
artist: "John Martyn",
releaseYear: 1973,
},
"title",
);
这种情况相对容易解决,因为每个参数都与其他参数兼容。但是当处理不兼容的参数时,事情可能会变得有点复杂。我们将在练习中进一步研究这一点。
练习
练习 1:接受除 null 和 undefined 之外的任何值
这里我们有一个函数 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"));
这些输入都不应引发错误。
然而,正如函数名称所示,如果我们向函数传递 null 或 undefined,我们希望它引发错误。
ts
acceptAnythingExceptNullOrUndefined(
// @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
null,
);
acceptAnythingExceptNullOrUndefined(
// @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
undefined,
);
你的任务是向 acceptAnythingExceptNullOrUndefined 函数添加一个类型注解,使其能够接受除 null 或 undefined 之外的任何值。
练习 2:检测对象中的额外属性
在这个练习中,我们处理一个 options 对象以及一个 FetchOptions 接口,该接口指定了 url、method、headers 和 body:
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 接口,具有 id 和 name 属性,以及一个包含两个用户对象 "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:遍历对象
考虑一个具有 id 和 name 属性的 User 接口,以及一个接受 User 作为其参数的 printUser 函数:
ts
interface User {
id: number;
name: string;
}
function printUser(user: User) {}
在测试设置中,我们希望使用 id 为 1 和 name 为 "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
});
更进一步,我们可以用 event、x 和 y 来调用它:
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
];
});
最后,该函数可以接受参数 event、x、y 和 screenID:
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:带有对象参数的函数联合类型
这里我们处理两个函数:logId 和 logName。logId 函数将对象中的 id 打印到控制台,而 logName 对 name 执行相同的操作:
这些函数被组合到一个名为 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 的对象,它包含以 string、number 或 boolean 为键的函数。每个键都有一个关联的函数来处理该类型的输入:
ts
const objOfFunctions = {
string: (input: string) => input.toUpperCase(),
number: (input: number) => input.toFixed(2),
boolean: (input: boolean) => (input ? "true" : "false"),
};
format 函数接受一个可以是 string、number 或 boolean 的输入。它通过常规的 typeof 运算符从此输入中提取类型,但它将该运算符断言为 string、number 或 boolean。
它看起来是这样的:
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 最终被类型化为函数的联合类型。这是因为它可能是接受 string、number 或 boolean 的任何一个函数:
ts
// 鼠标悬停在 formatter 上显示:
const formatter:
| ((input: string) => string)
| ((input: number) => string)
| ((input: boolean) => "true" | "false");
目前 format 函数的返回语句中的 input 有一个错误。你的挑战是在类型级别解决此错误,即使代码在运行时可以工作。尝试使用断言作为一种解决方案,使用类型守卫作为另一种解决方案。
一个有用的提示------any 不能分配给 never。
解决方案 1:接受除 null 和 undefined 之外的任何值
解决方案是向 input 参数添加一个空对象注解:
ts
const acceptAnythingExceptNullOrUndefined = (input: {}) => {};
由于 input 参数被类型化为空对象,它将接受除 null 或 undefined 之外的任何值。
解决方案 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 将是属性名称的联合类型,例如 id 或 name。通过使用 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 字符串。
这是有道理的,因为当我们调用数组时,我们不知道在哪个时间点得到哪一个。
我们可以对 id 和 name 使用带有对象的交集类型:
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 将始终是 string、number 或 boolean。
有趣的是,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 好,但它会按预期修复错误。