模板字面量类型
TypeScript 中的字符串字面量类型允许我们对期望一组特定字符串的函数和 API 进行建模。
tsTryfunctionsetVerticalAlignment (location : "top" | "middle" | "bottom") {// ...}Argument of type '"middel"' is not assignable to parameter of type '"top" | "middle" | "bottom"'.2345Argument of type '"middel"' is not assignable to parameter of type '"top" | "middle" | "bottom"'.setVerticalAlignment ("middel" );
这非常不错,因为字符串字面量类型基本上可以对我们的字符串值进行拼写检查。
我们还喜欢将字符串字面量用作映射类型中的属性名。从这个意义上说,它们也可以用作构建块。
tstype Options = {[K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean;};// same as// type Options = {// noImplicitAny?: boolean,// strictNullChecks?: boolean,// strictFunctionTypes?: boolean// };
但字符串字面量类型还可以作为另一个领域的构建块:构建其他字符串字面量类型。
这就是 TypeScript 4.1 引入模板字面量字符串类型的原因。它具有与 JavaScript 中的模板字面量字符串相同的语法,但用于类型位置。当你将其与具体的字面量类型一起使用时,它会通过拼接内容生成一个新的字符串字面量类型。
tsTrytypeWorld = "world";typeGreeting = `hello ${World }`;
当替换位置有联合类型时会发生什么?它会产生由每个联合成员所能表示的所有可能的字符串字面量的集合。
tsTrytypeColor = "red" | "blue";typeQuantity = "one" | "two";typeSeussFish = `${Quantity |Color } fish`;
这不仅仅可以用于发行说明中的简单示例。例如,一些 UI 组件库可以通过其 API 指定垂直和水平对齐方式,通常使用像 "bottom-right" 这样的单个字符串同时指定两者。在垂直对齐 "top"、"middle" 和 "bottom" 以及水平对齐 "left"、"center" 和 "right" 之间,有 9 种可能的字符串,其中每个前置字符串都通过连字符与每个后置字符串相连。
tsTrytypeVerticalAlignment = "top" | "middle" | "bottom";typeHorizontalAlignment = "left" | "center" | "right";// Takes// | "top-left" | "top-center" | "top-right"// | "middle-left" | "middle-center" | "middle-right"// | "bottom-left" | "bottom-center" | "bottom-right"declare functionsetAlignment (value : `${VerticalAlignment }-${HorizontalAlignment }`): void;setAlignment ("top-left"); // works!Argument of type '"top-middel"' is not assignable to parameter of type '"top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"'.2345Argument of type '"top-middel"' is not assignable to parameter of type '"top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"'.setAlignment ("top-middel" ); // error!Argument of type '"top-pot"' is not assignable to parameter of type '"top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"'.2345Argument of type '"top-pot"' is not assignable to parameter of type '"top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"'.setAlignment ("top-pot" ); // error! but good doughnuts if you're ever in Seattle
虽然这类 API 在实际应用中很多,但这仍然只是一个玩具示例,因为我们可以手动写出这些组合。事实上,对于 9 个字符串来说这可能没问题;但当你需要大量字符串时,你应该考虑提前自动生成它们,以节省每次类型检查的工作量(或者干脆使用 string,这样更容易理解)。
一些真正的价值来自于动态创建新的字符串字面量。例如,想象一个 makeWatchedObject API,它接收一个对象并生成一个基本相同的对象,但带有一个新的 on 方法来检测属性的变化。
tslet person = makeWatchedObject({firstName: "Homer",age: 42, // give-or-takelocation: "Springfield",});person.on("firstNameChanged", () => {console.log(`firstName was changed!`);});
请注意,on 监听的是事件 "firstNameChanged",而不仅仅是 "firstName"。我们该如何为它编写类型呢?
tstype PropEventSource<T> = {on(eventName: `${string & keyof T}Changed`, callback: () => void): void;};/// Create a "watched object" with an 'on' method/// so that you can watch for changes to properties.declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;
有了这个,我们就可以构建出一种在我们给出错误属性时报错的东西!
tsTry// error!Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "ageChanged" | "locationChanged"'.2345Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "ageChanged" | "locationChanged"'.person .on ("firstName" , () => {});// error!Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "ageChanged" | "locationChanged"'.2345Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "ageChanged" | "locationChanged"'.person .on ("frstNameChanged" , () => {});
我们还可以在模板字面量类型中做一些特别的事情:我们可以从替换位置推断类型。我们可以使上一个示例通用化,从 eventName 字符串的部分中推断出关联的属性。
tsTrytypePropEventSource <T > = {on <K extends string & keyofT >(eventName : `${K }Changed`,callback : (newValue :T [K ]) => void ): void;};declare functionmakeWatchedObject <T >(obj :T ):T &PropEventSource <T >;letperson =makeWatchedObject ({firstName : "Homer",age : 42,location : "Springfield",});// works! 'newName' is typed as 'string'person .on ("firstNameChanged",newName => {// 'newName' has the type of 'firstName'console .log (`new name is ${newName .toUpperCase ()}`);});// works! 'newAge' is typed as 'number'person .on ("ageChanged",newAge => {if (newAge < 0) {console .log ("warning! negative age");}})
在这里,我们将 on 变成了一个泛型方法。当用户使用字符串 "firstNameChanged" 调用时,TypeScript 会尝试为 K 推断出正确的类型。为了做到这一点,它会将 K 与 "Changed" 之前的内容进行匹配,并推断出字符串 "firstName"。一旦 TypeScript 确定了这一点,on 方法就可以获取原始对象上 firstName 的类型,在本例中为 string。同样,当我们用 "ageChanged" 调用时,它会找到属性 age 的类型,即 number。
推断可以以不同的方式组合,通常用于解构字符串,并以不同的方式重构它们。事实上,为了帮助修改这些字符串字面量类型,我们添加了一些新的工具类型别名,用于修改字母的大小写(即转换为小写和大写字符)。
tsTrytypeEnthusiasticGreeting <T extends string> = `${Uppercase <T >}`typeHELLO =EnthusiasticGreeting <"hello">;
新的类型别名是 Uppercase、Lowercase、Capitalize 和 Uncapitalize。前两个转换字符串中的每个字符,后两个仅转换字符串中的第一个字符。
更多详细信息,请查看原始拉取请求和正在进行的切换到类型别名助手的拉取请求。
映射类型中的键重映射
作为复习,映射类型可以基于任意键创建新的对象类型
tstype Options = {[K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean;};// same as// type Options = {// noImplicitAny?: boolean,// strictNullChecks?: boolean,// strictFunctionTypes?: boolean// };
或者基于其他对象类型创建新的对象类型。
ts/// 'Partial<T>' is the same as 'T', but with each property marked optional.type Partial<T> = {[K in keyof T]?: T[K];};
到目前为止,映射类型只能产生具有你提供给它们的键的新对象类型;然而,很多时候你希望能够基于输入来创建新键或过滤掉某些键。
这就是为什么 TypeScript 4.1 允许你通过新的 as 子句在映射类型中重新映射键。
tstype MappedTypeWithNewKeys<T> = {[K in keyof T as NewKeyType]: T[K]// ^^^^^^^^^^^^^// This is the new syntax!}
有了这个新的 as 子句,你可以利用模板字面量类型等特性,轻松地基于旧属性名创建新的属性名。
tsTrytypeGetters <T > = {[K in keyofT as `get${Capitalize <string &K >}`]: () =>T [K ]};interfacePerson {name : string;age : number;location : string;}typeLazyPerson =Getters <Person >;
你甚至可以通过产生 never 来过滤掉键。这意味着在某些情况下,你不必使用额外的 Omit 辅助类型。
tsTry// Remove the 'kind' propertytypeRemoveKindField <T > = {[K in keyofT asExclude <K , "kind">]:T [K ]};interfaceCircle {kind : "circle";radius : number;}typeKindlessCircle =RemoveKindField <Circle >;
欲了解更多信息,请查看 GitHub 上的原始拉取请求。
递归条件类型
在 JavaScript 中,看到能够以任意级别展平并构建容器类型的函数是很常见的。例如,考虑 Promise 实例上的 .then() 方法。.then(...) 会解开每个 Promise,直到找到一个“非 Promise”的值,并将该值传递给回调。在 Array 上还有一个相对较新的 flat 方法,它可以接受一个深度参数来决定展平的深度。
在 TypeScript 的类型系统中表达这一点,在所有实际意义上都是不可能的。虽然有一些 hack 方法可以实现这一点,但最终的类型看起来非常不合理。
这就是 TypeScript 4.1 放宽了一些条件类型限制的原因——以便它们可以对这些模式进行建模。在 TypeScript 4.1 中,条件类型现在可以在其分支内立即引用自身,从而更容易编写递归类型别名。
例如,如果我们想编写一个获取嵌套数组元素类型的类型,我们可以编写以下 deepFlatten 类型。
tstype ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {throw "not implemented";}// All of these return the type 'number[]':deepFlatten([1, 2, 3]);deepFlatten([[1], [2, 3]]);deepFlatten([[1], [[2]], [[[3]]]]);
同样,在 TypeScript 4.1 中,我们可以编写一个 Awaited 类型来深度解开 Promise。
tstype Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;/// Like `promise.then(...)`, but more accurate in types.declare function customThen<T, U>(p: Promise<T>,onFulfilled: (value: Awaited<T>) => U): Promise<Awaited<U>>;
请记住,虽然这些递归类型很强大,但应负责任且节制地使用它们。
首先,这些类型可能会执行大量工作,这意味着它们会增加类型检查时间。尝试在 Collatz 猜想或斐波那契序列中模拟数字可能很有趣,但不要将这些内容发布在 npm 的 .d.ts 文件中。
除了计算密集之外,这些类型在处理足够复杂的输入时可能会达到内部递归深度限制。当达到该递归限制时,会导致编译时错误。总的来说,最好完全不使用这些类型,而不是编写在更现实的示例中失败的代码。
查看更多实现详情。
检查索引访问 (--noUncheckedIndexedAccess)
TypeScript 有一个称为索引签名的特性。这些签名是一种向类型系统发出信号的方式,即用户可以访问任意命名的属性。
tsTryinterfaceOptions {path : string;permissions : number;// Extra properties are caught by this index signature.[propName : string]: string | number;}functioncheckOptions (opts :Options ) {opts .path ; // stringopts .permissions ; // number// These are all allowed too!// They have the type 'string | number'.opts .yadda .toString ();opts ["foo bar baz"].toString ();opts [Math .random ()].toString ();}
在上面的例子中,Options 有一个索引签名,表示任何未列出的已访问属性都应具有 string | number 类型。这通常对于那些假设你知道自己在做什么的乐观代码来说很方便,但事实是 JavaScript 中的大多数值并不支持每个潜在的属性名。例如,大多数类型不会拥有像前一个例子中由 Math.random() 创建的属性键的值。对于许多用户来说,这种行为是不可取的,并且感觉没有充分利用 strictNullChecks 的严格检查。
这就是为什么 TypeScript 4.1 引入了一个名为 noUncheckedIndexedAccess 的新标志。在这种新模式下,每个属性访问(如 foo.bar)或索引访问(如 foo["bar"])都被认为是可能未定义的。这意味着在我们的最后一个例子中,opts.yadda 的类型将是 string | number | undefined,而不是仅仅是 string | number。如果你需要访问该属性,你要么必须先检查它的存在性,要么使用非空断言运算符(后缀 ! 字符)。
tsTryfunctioncheckOptions (opts :Options ) {opts .path ; // stringopts .permissions ; // number// These are not allowed with noUncheckedIndexedAccess'opts.yadda' is possibly 'undefined'.18048'opts.yadda' is possibly 'undefined'.opts .yadda .toString ();Object is possibly 'undefined'.2532Object is possibly 'undefined'.opts ["foo bar baz"].toString ();Object is possibly 'undefined'.2532Object is possibly 'undefined'.opts [Math .random ()].toString ();// Checking if it's really there first.if (opts .yadda ) {console .log (opts .yadda .toString ());}// Basically saying "trust me I know what I'm doing"// with the '!' non-null assertion operator.opts .yadda !.toString ();}
使用 noUncheckedIndexedAccess 的一个后果是,即使在边界检查的循环中,对数组的索引也会受到更严格的检查。
tsTryfunctionscreamLines (strs : string[]) {// This will have issuesfor (leti = 0;i <strs .length ;i ++) {Object is possibly 'undefined'.2532Object is possibly 'undefined'.console .log (strs [i ].toUpperCase ());}}
如果你不需要索引,可以使用 for-of 循环或 forEach 调用来遍历单个元素。
tsTryfunctionscreamLines (strs : string[]) {// This works finefor (conststr ofstrs ) {console .log (str .toUpperCase ());}// This works finestrs .forEach ((str ) => {console .log (str .toUpperCase ());});}
此标志对于捕获越界错误很有用,但对于许多代码来说可能过于吵闹,因此它不会被 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{"": {"": "esnext","": "es2015","": "react-jsx","": true},"": ["./**/*"]}
而用于开发构建的可能如下所示
// ./src/tsconfig.dev.json{"": "./tsconfig.json","": {"": "react-jsxdev"}}
欲了解更多信息,请查看相应的 PR。
JSDoc @see 标签的编辑器支持
JSDoc @see 标签现在在 TypeScript 和 JavaScript 编辑器中获得了更好的支持。这允许你在标签后的点号名称上使用诸如“转到定义”之类的功能。例如,在以下示例中,转到 JSDoc 注释中的 first 或 C 的定义均可正常工作
ts// @filename: first.tsexport class C {}// @filename: main.tsimport * 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 }。
tsdeclare let foo: unknown;declare let somethingElse: { someProp: string };let x = foo && somethingElse;
然而,在 TypeScript 4.1 中,我们更加谨慎地确定这种类型。由于 && 左侧的类型未知,我们将 any 和 unknown 向外传播,而不是右侧的类型。
我们看到的最常见的模式倾向于在检查与 boolean 的兼容性时发生,特别是在谓词函数中。
tsfunction isThing(x: any): boolean {return x && typeof x === "object" && x.blah === "foo";}
通常,合适的修复方法是将 foo && someExpression 切换为 !!foo && someExpression。
resolve 的参数在 Promise 中不再是可选的
当编写如下代码时
tsnew Promise((resolve) => {doSomethingAsync(() => {doSomething();resolve();});});
你可能会遇到类似以下的错误
resolve()~~~~~~~~~error TS2554: Expected 1 arguments, but got 0.An argument for 'value' was not provided.
这是因为 resolve 不再有可选参数,因此默认情况下,现在必须传递一个值。这通常可以捕获使用 Promise 时的合法错误。典型的修复方法是传递正确的参数,有时还需要添加显式的类型参数。
tsnew Promise<number>((resolve) => {// ^^^^^^^^doSomethingAsync((value) => {doSomething();resolve(value);// ^^^^^});});
然而,有时 resolve() 确实需要不带参数地调用。在这些情况下,我们可以给 Promise 一个显式的 void 泛型类型参数(即写成 Promise<void>)。这利用了 TypeScript 4.1 中的新功能,即潜在的 void 尾随参数可以变为可选。
tsnew Promise<void>((resolve) => {// ^^^^^^doSomethingAsync(() => {doSomething();resolve();});});
TypeScript 4.1 附带了一个快速修复程序来帮助解决这个破坏性更改。
条件传播创建可选属性
在 JavaScript 中,对象传播(如 { ...foo })不对假值进行操作。因此,在像 { ...foo } 这样的代码中,如果 foo 是 null 或 undefined,它将被跳过。
许多用户利用这一点来“有条件地”传播属性。
tsinterface Person {name: string;age: number;location: string;}interface Animal {name: string;owner: Person;}function copyOwner(pet?: Animal) {return {...(pet && pet.owner),otherStuff: 123,};}// We could also use optional chaining here: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 中可能会选择不同的重载,有时会导致下游出现新的或不同的错误。
作为变通方法,你最好使用类型断言来压制错误。