TypeScript 4.1
模板字面量类型
TypeScript 中的字符串字面量类型允许我们对期望一组特定字符串的函数和 API 进行建模。
function setVerticalAlignment(location: "top" | "middle" | "bottom") {
// ...
}
setVerticalAlignment("middel");Try这非常好,因为字符串字面量类型基本上可以拼写检查我们的字符串值。
我们也喜欢字符串字面量可以用作映射类型中的属性名称。 从这个意义上说,它们也可以用作构建块:
type Options = {
[K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean;
};
// 等同于
// type Options = {
// noImplicitAny?: boolean,
// strictNullChecks?: boolean,
// strictFunctionTypes?: boolean
// };但还有另一个地方可以将字符串字面量类型用作构建块:构建其他字符串字面量类型。
这就是 TypeScript 4.1 带来模板字面量字符串类型的原因。 它与 JavaScript 中的模板字面量字符串 具有相同的语法,但用于类型位置。 当你将其与具体字面量类型一起使用时,它会通过连接内容生成一个新的字符串字面量类型。
当你在替换位置有联合类型时会发生什么? 它会生成每个联合成员可以表示的每个可能字符串字面量的集合。
type Color = "red" | "blue";
type Quantity = "one" | "two";
type SeussFish = `${Quantity | Color} fish`;
Try这可以在发布说明中的可爱示例之外使用。 例如,一些 UI 组件库在其 API 中有一种指定垂直和水平对齐的方式,通常使用单个字符串(如 "bottom-right")同时指定两者。 在垂直对齐使用 "top"、"middle" 和 "bottom",水平对齐使用 "left"、"center" 和 "right" 的情况下,有 9 种可能的字符串,其中每个前者的字符串通过破折号与每个后者的字符串连接。
type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
// 接受
// | "top-left" | "top-center" | "top-right"
// | "middle-left" | "middle-center" | "middle-right"
// | "bottom-left" | "bottom-center" | "bottom-right"
declare function setAlignment(value: `${VerticalAlignment}-${HorizontalAlignment}`): void;
setAlignment("top-left"); // 有效!
setAlignment("top-middel"); // 错误!setAlignment("top-pot"); // 错误!但如果你在西雅图,这是个好甜甜圈Try虽然这类 API 在现实中有很多例子,但这仍然有点玩具示例,因为我们可以手动写出这些字符串。 事实上,对于 9 个字符串,这可能是可以的;但当你需要大量字符串时,你应该考虑提前自动生成它们以节省每次类型检查的工作(或者只使用 string,这会更容易理解)。
一些真正的价值来自于动态创建新的字符串字面量。 例如,想象一个 makeWatchedObject API,它接受一个对象并产生一个几乎相同的对象,但有一个新的 on 方法来检测属性的更改。
let person = makeWatchedObject({
firstName: "Homer",
age: 42, // 大约
location: "Springfield",
});
person.on("firstNameChanged", () => {
console.log(`firstName was changed!`);
});注意 on 监听的是 "firstNameChanged" 事件,而不仅仅是 "firstName"。 我们该如何为此类型化?
type PropEventSource<T> = {
on(eventName: `${string & keyof T}Changed`, callback: () => void): void;
};
/// 创建一个带有 'on' 方法的“被观察对象”
/// 以便你可以观察属性的变化。
declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;有了这个,我们可以在给出错误属性时构建一个会报错的东西!
我们还可以在模板字面量类型中做一些特别的事情:我们可以从替换位置进行推断。 我们可以使最后一个例子泛型化,从 eventName 字符串的部分推断出关联的属性。
type PropEventSource<T> = {
on<K extends string & keyof T>
(eventName: `${K}Changed`, callback: (newValue: T[K]) => void ): void;
};
declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;
let person = makeWatchedObject({
firstName: "Homer",
age: 42,
location: "Springfield",
});
// 有效!'newName' 的类型为 'string'
person.on("firstNameChanged", newName => {
// 'newName' 具有 'firstName' 的类型
console.log(`new name is ${newName.toUpperCase()}`);
});
// 有效!'newAge' 的类型为 'number'
person.on("ageChanged", newAge => {
if (newAge < 0) {
console.log("warning! negative age");
}
})Try这里我们将 on 变成了一个泛型方法。 当用户使用字符串 "firstNameChanged" 调用时,TypeScript 将尝试为 K 推断正确的类型。 为此,它会将 K 与 "Changed" 之前的内容进行匹配,并推断字符串 "firstName"。 一旦 TypeScript 弄清楚了这一点,on 方法就可以获取原始对象上 firstName 的类型,在本例中是 string。 类似地,当我们用 "ageChanged" 调用时,它会找到属性 age 的类型,即 number。
推断可以以不同的方式组合,通常是解构字符串,并以不同的方式重构它们。 实际上,为了帮助修改这些字符串字面量类型,我们添加了几个新的实用类型别名来更改字母的大小写(即转换为小写和大写字符)。
type EnthusiasticGreeting<T extends string> = `${Uppercase<T>}`
type HELLO = EnthusiasticGreeting<"hello">;
Try新的类型别名是 Uppercase、Lowercase、Capitalize 和 Uncapitalize。 前两个转换字符串中的每个字符,后两个仅转换字符串中的第一个字符。
有关更多详细信息,请参阅原始拉取请求和转换为类型别名助手的进行中拉取请求。
映射类型中的键重映射
回顾一下,映射类型可以基于任意键创建新的对象类型
type Options = {
[K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean;
};
// 等同于
// type Options = {
// noImplicitAny?: boolean,
// strictNullChecks?: boolean,
// strictFunctionTypes?: boolean
// };或者基于其他对象类型创建新的对象类型。
/// 'Partial<T>' 与 'T' 相同,但每个属性都标记为可选。
type Partial<T> = {
[K in keyof T]?: T[K];
};到目前为止,映射类型只能使用你提供给它们的键来生成新的对象类型;然而,很多时候,你希望能够根据输入创建新的键,或者过滤掉键。
这就是为什么 TypeScript 4.1 允许你在映射类型中使用新的 as 子句重新映射键。
type MappedTypeWithNewKeys<T> = {
[K in keyof T as NewKeyType]: T[K]
// ^^^^^^^^^^^^^
// 这是新语法!
}借助这个新的 as 子句,你可以利用模板字面量类型等功能,轻松地基于旧属性名称创建新属性名称。
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
interface Person {
name: string;
age: number;
location: string;
}
type LazyPerson = Getters<Person>;
Try你甚至可以通过产生 never 来过滤掉键。 这意味着在某些情况下,你不必使用额外的 Omit 辅助类型。
// 移除 'kind' 属性
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, "kind">]: T[K]
};
interface Circle {
kind: "circle";
radius: number;
}
type KindlessCircle = RemoveKindField<Circle>;
Try有关更多信息,请查看 GitHub 上的原始拉取请求。
递归条件类型
在 JavaScript 中,看到可以展平和构建任意深度的容器类型的函数是很常见的。 例如,考虑 Promise 实例上的 .then() 方法。 .then(...) 会展开每个 promise,直到找到一个不是“类 promise”的值,并将该值传递给回调。 还有一个相对较新的 Array 上的 flat 方法,它可以接受一个深度参数来指定展平深度。
在 TypeScript 的类型系统中表达这一点,实际上是不可能的。 虽然有实现这一点的技巧,但类型最终看起来非常不合理。
这就是为什么 TypeScript 4.1 放宽了对条件类型的一些限制——以便它们可以对这些模式进行建模。 在 TypeScript 4.1 中,条件类型现在可以立即在其分支内引用自身,从而更容易编写递归类型别名。
例如,如果我们想编写一个类型来获取嵌套数组的元素类型,我们可以编写下面的 deepFlatten 类型。
type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;
function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {
throw "not implemented";
}
// 所有这些都返回类型 'number[]':
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);类似地,在 TypeScript 4.1 中,我们可以编写一个 Awaited 类型来深度展开 Promise。
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
/// 就像 `promise.then(...)`,但类型更准确。
declare function customThen<T, U>(
p: Promise<T>,
onFulfilled: (value: Awaited<T>) => U
): Promise<Awaited<U>>;请记住,虽然这些递归类型很强大,但应该负责任地、谨慎地使用它们。
首先,这些类型可以完成大量工作,这意味着它们可能会增加类型检查时间。 尝试在科拉茨猜想或斐波那契数列中建模数字可能很有趣,但不要在 npm 上的 .d.ts 文件中发布它。
但除了计算密集之外,这些类型在足够复杂的输入上可能会达到内部递归深度限制。 当达到该递归限制时,会导致编译时错误。 通常,最好不要使用这些类型,而不是编写在更现实的示例上会失败的东西。
查看更多实现细节。
检查索引访问 (--noUncheckedIndexedAccess)
TypeScript 有一个称为索引签名的特性。 这些签名是向类型系统发出信号的一种方式,表明用户可以访问任意命名的属性。
interface Options {
path: string;
permissions: number;
// 额外属性由此索引签名捕获。
[propName: string]: string | number;
}
function checkOptions(opts: Options) {
opts.path; // string
opts.permissions; // number
// 这些也都是允许的!
// 它们具有类型 'string | number'。
opts.yadda.toString();
opts["foo bar baz"].toString();
opts[Math.random()].toString();
}Try在上面的例子中,Options 有一个索引签名,表示任何未列出的被访问属性都应具有类型 string | number。 这对于假设你知道自己在做什么的乐观代码来说通常是方便的,但事实上,JavaScript 中的大多数值并不支持每一个潜在的属性名称。 例如,大多数类型不会像上一个例子中那样有为 Math.random() 创建的属性键提供值。 对于许多用户来说,这种行为是不希望的,并且感觉它没有充分利用 strictNullChecks 的严格检查。
这就是为什么 TypeScript 4.1 附带了一个名为 noUncheckedIndexedAccess 的新标志。 在此新模式下,每个属性访问(如 foo.bar)或索引访问(如 foo["bar"])都被视为可能为 undefined。 这意味着在我们最后的例子中,opts.yadda 将具有类型 string | number | undefined,而不是仅仅是 string | number。 如果你需要访问该属性,你必须首先检查它是否存在,或者使用非空断言运算符(后缀 ! 字符)。
function checkOptions(opts: Options) {
opts.path; // string
opts.permissions; // number
// 使用 noUncheckedIndexedAccess 时不允许这些
opts.yadda.toString(); opts["foo bar baz"].toString(); opts[Math.random()].toString();
// 先检查它是否真的存在。
if (opts.yadda) {
console.log(opts.yadda.toString());
}
// 基本上说“相信我,我知道我在做什么”
// 使用 '!' 非空断言运算符。
opts.yadda!.toString();
}Try使用 noUncheckedIndexedAccess 的一个后果是,即使在边界检查循环中,对数组的索引也会受到更严格的检查。
function screamLines(strs: string[]) {
// 这会有问题
for (let i = 0; i < strs.length; i++) {
console.log(strs[i].toUpperCase()); }
}Try如果你不需要索引,你可以使用 for-of 循环或 forEach 调用来迭代单个元素。
function screamLines(strs: string[]) {
// 这正常工作
for (const str of strs) {
console.log(str.toUpperCase());
}
// 这正常工作
strs.forEach((str) => {
console.log(str.toUpperCase());
});
}Try此标志对于捕获越界错误很有用,但对于许多代码来说可能很嘈杂,因此它不会由 strict 标志自动启用;但是,如果你对此功能感兴趣,你应该随意尝试并确定它是否适合你团队的代码库!
你可以在实现拉取请求中了解更多信息。
没有 baseUrl 的 paths
使用路径映射相当常见——通常是为了获得更好的导入,或者模拟 monorepo 链接行为。
不幸的是,指定 paths 来启用路径映射还需要指定一个称为 baseUrl 的选项,这也允许相对于 baseUrl 到达裸说明符路径。 这也经常导致自动导入使用较差的路径。
在 TypeScript 4.1 中,paths 选项可以在没有 baseUrl 的情况下使用。 这有助于避免其中一些问题。
checkJs 隐含 allowJs
以前,如果你开始一个受检查的 JavaScript 项目,你必须同时设置 allowJs 和 checkJs。 这在体验中是一个有点烦人的摩擦点,所以 checkJs 现在默认隐含 allowJs。
React 17 JSX 工厂
TypeScript 4.1 通过 jsx 编译器选项的两个新选项支持 React 17 即将推出的 jsx 和 jsxs 工厂函数:
react-jsxreact-jsxdev
这些选项分别用于生产编译和开发编译。 通常,一个选项的选项可以从另一个选项扩展。 例如,用于生产构建的 tsconfig.json 可能如下所示:
// ./src/tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"target": "es2015",
"jsx": "react-jsx",
"strict": true
},
"include": ["./**/*"]
}而用于开发构建的可能如下所示:
// ./src/tsconfig.dev.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"jsx": "react-jsxdev"
}
}有关更多信息,请查看相应的 PR。
对 JSDoc @see 标签的编辑器支持
JSDoc 标签 @see 标签现在在 TypeScript 和 JavaScript 的编辑器中得到了更好的支持。 这允许你在标签后面的点号名称上使用跳转到定义等功能。 例如,在 JSDoc 注释中的 first 或 C 上跳转到定义在以下示例中就可以正常工作:
// @filename: first.ts
export class C {}
// @filename: main.ts
import * as first from "./first";
/**
* @see first.C
*/
function related() {}感谢频繁贡献者 Wenlu Wang 实现了此功能!
破坏性更改
lib.d.ts 更改
lib.d.ts 可能有一组更改的 API,部分原因可能是 DOM 类型的自动生成方式。 一个具体的更改是 Reflect.enumerate 已被移除,因为它已从 ES2016 中移除。
abstract 成员不能标记为 async
标记为 abstract 的成员不能再标记为 async。 修复方法是移除 async 关键字,因为调用者只关心返回类型。
any/unknown 在假值位置传播
以前,对于像 foo && somethingElse 这样的表达式,如果 foo 的类型是 any 或 unknown,那么整个表达式的类型将是 somethingElse 的类型。
例如,以前这里 x 的类型是 { someProp: string }。
declare let foo: unknown;
declare let somethingElse: { someProp: string };
let x = foo && somethingElse;然而,在 TypeScript 4.1 中,我们在确定此类型时更加谨慎。 由于对 && 左侧的类型一无所知,我们向外传播 any 和 unknown,而不是右侧的类型。
我们看到的这种最常见模式是检查与 boolean 的兼容性时,尤其是在谓词函数中。
function isThing(x: any): boolean {
return x && typeof x === "object" && x.blah === "foo";
}通常,适当的修复方法是将 foo && someExpression 切换为 !!foo && someExpression。
Promise 中 resolve 的参数不再是可选的
在编写如下代码时
new Promise((resolve) => {
doSomethingAsync(() => {
doSomething();
resolve();
});
});你可能会遇到如下错误:
resolve()
~~~~~~~~~
error TS2554: Expected 1 arguments, but got 0.
An argument for 'value' was not provided.这是因为 resolve 不再有可选参数,因此默认情况下,现在必须传递一个值。 这通常会捕获使用 Promise 时的合法 bug。 典型的修复方法是传递正确的参数,有时添加一个显式的类型参数。
new Promise<number>((resolve) => {
// ^^^^^^^^
doSomethingAsync((value) => {
doSomething();
resolve(value);
// ^^^^^
});
});然而,有时 resolve() 确实需要在没有参数的情况下调用。 在这些情况下,我们可以给 Promise 一个显式的 void 泛型类型参数(即将其写为 Promise<void>)。 这利用了 TypeScript 4.1 中的新功能,即潜在的 void 尾随参数可以变为可选。
new Promise<void>((resolve) => {
// ^^^^^^
doSomethingAsync(() => {
doSomething();
resolve();
});
});TypeScript 4.1 附带了一个快速修复来帮助解决此中断。
条件展开创建可选属性
在 JavaScript 中,对象展开(如 { ...foo })不会对假值进行操作。 因此,在像 { ...foo } 这样的代码中,如果 foo 是 null 或 undefined,它将被跳过。
许多用户利用这一点来“有条件地”展开属性。
interface Person {
name: string;
age: number;
location: string;
}
interface Animal {
name: string;
owner: Person;
}
function copyOwner(pet?: Animal) {
return {
...(pet && pet.owner),
otherStuff: 123,
};
}
// 我们也可以在这里使用可选链:
function copyOwner(pet?: Animal) {
return {
...pet?.owner,
otherStuff: 123,
};
}这里,如果 pet 被定义,pet.owner 的属性将被展开——否则,没有属性会被展开到返回的对象中。
copyOwner 的返回类型以前是基于每个展开的联合类型:
{ x: number } | { x: number, name: string, age: number, location: string }这精确地模拟了操作将如何发生:如果 pet 被定义,Person 的所有属性都将存在;否则,结果上都不会定义它们。 这是一个全有或全无的操作。
然而,我们已经看到这种模式被发挥到了极致,在一个对象中进行数百次展开,每次展开可能添加数百或数千个属性。 事实证明,出于各种原因,这最终会非常昂贵,而且通常收益不大。
在 TypeScript 4.1 中,返回的类型有时会使用全可选属性。
{
x: number;
name?: string;
age?: number;
location?: string;
}这最终会表现得更好,并且通常显示得也更好。
有关更多详细信息,请参阅原始更改。 虽然此行为目前不完全一致,但我们预计未来版本将产生更清晰、更可预测的结果。
不匹配的参数不再关联
以前,TypeScript 会将彼此不对应的参数通过将它们关联到 any 类型来进行关联。 通过 TypeScript 4.1 中的更改,语言现在完全跳过了这个过程。 这意味着一些可赋值性的情况现在将失败,但这也意味着一些重载解析的情况也可能失败。 例如,Node.js 中 util.promisify 的重载解析可能在 TypeScript 4.1 中选择不同的重载,有时会导致下游出现新的或不同的错误。
作为一种解决方法,你最好使用类型断言来消除错误。