TypeScript 问题收集
更新点
import type
在以往版本中,如果我们要导入一个值或类型,直接全部导入即可,在 TS 转 JS 的时候,TS 会识别出那些导入项被当做类型使用,并将其删除。但是有时候 TS 并不能识别我们导入的到底是值还是类型。Typescript 3.8 里新增了一个新的语法 import type
,即仅导入类型。同样 export type
仅提供一个用于类型的导出,最终都会被删除:
import type { SomeThing } from "./some-module.js"
export type { SomeThing }
if you’ve hit issues under
--isolatedModules
, TypeScript’stranspileModule
API, orBabel
, this feature might be relevant.
需要注意的是,当使用仅类型导入功能时,导入 class 的话不能对其进行扩展:
import type { Component } from "react"
interface ButtonProps {
// ...
}
class Button extends Component<ButtonProps> {
// ~~~~~~~~~
// error! 'Component' only refers to a type, but is being used as a value here.
// ...
}
号外号外,4.5 版本又更新了写法:
- import type { BaseType } from "./some-module.js"
- import { someFunc } from "./some-module.js"
+ import { someFunc, type BaseType } from "./some-module.js"
Top-Level Await
一般情况下我们在使用 await 语法的时候,必须包裹在 async 函数中,然后立即调用该函数来执行异步操作:
async function main() {
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
}
main().catch((e) => console.error(e));
Typescript 3.8 新增了 Top-Level Await
来省去这部分包装代码。此外注意一点,Top-Level await
只在顶级模块工作,所以代码中需要含有 export 或者 import 才会认为该文件是一个模块。对于没有依赖的情况下,可以使用 export {}
:
// 顶级模块
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
// Make sure we're a module
export {};
bigint
Typescript 3.2 针对于 BigInt 类型做了支持,表示为小写的 bigint。需要注意的是,bigint 和 number 是两种不同的类型:
declare let foo: number;
declare let bar: bigint;
foo = bar; // error: Type 'bigint' is not assignable to type 'number'.
bar = foo; // error: Type 'number' is not assignable to type 'bigint'.
// 混合计算使用的时候,必须将数字类型转换为 bigint
console.log(3.141592 * 10000n); // error
console.log(3145 * 10n); // error
console.log(BigInt(3145) * 10n); // okay!
@ts-nocheck / @ts-ignore
为了让用户能从 JS 到 TS 平滑过渡,原先能通过 --allowJs
来支持 TS 和 JS 文件的混合在一起编译,不会对 JS 文件去做类型检查。Typescript 2.3 可以通过两种方式来实现对混合在一起的 JS 文件一起做类型检查:
- 通过开启
--checkJs
配置来对 JS 文件去做检测。在 JS 文件中,可以通过在顶部添加@ts-nocheck
注释跳过该文件类型检测,也可以通过添加@ts-ignore
注释跳过下一行的文件类型检测 - 直接通过
@ts-check
对 JS 文件去做检测
并且做类型检测的时候,还可以根据 jsdoc 的类型注解自动来进行判断:
// @ts-check
/**
* @param {string} input
*/
function foo ( input ) {
input.tolowercase ()
// ~~~~~~~~~~~ Error! Should be toLowerCase
}
到了 Typescript 3.7,
@ts-nocheck
的注释也可以不仅仅针对 JS 文件,同样可以作用于 TS 文件。
到了 Typescript 4.1,设置
--checkJs
的时候会默认开通--allowJS
@ts-expect-error
当我们通过 @ts-expect-error
来进行注释时,TS 抛出的类型错误会被忽略;如果没有错误,则会抛出异常 Unused '@ts-expect-error' directive.
。通常我们的使用场景可能是单元测试,比如:
function doStuff(abc: string, xyz: string) {
assert(typeof abc === "string");
assert(typeof xyz === "string");
// do some stuff
}
// 单元测试
expect(() => {
doStuff(123, 456);
}).toThrow();
如果单元测试也是用 TS 来编写的话,那么我们可以看到如下的报错:
doStuff(123, 456);
// ~~~
// error: Type 'number' is not assignable to type 'string'.
因此通过上述的注释我们就能跳过这个类型检测。但有一个问题,它在功能上和 @ts-ignore
很相似呀,为啥要单独引入这个指令呢,你会 pick 谁?下面是官网贴出来的两者的比较:
- Pick ts-expect-error if:
- you’re writing test code where you actually want the type system to error on an operation
- you expect a fix to be coming in fairly quickly and you just need a quick workaround
- you’re in a reasonably-sized project with a proactive team that wants to remove suppression comments as soon affected code is valid again
- Pick ts-ignore if:
- you have a larger project and new errors have appeared in code with no clear owner
- you are in the middle of an upgrade between two different versions of TypeScript, and a line of code errors in one version but not another.
- you honestly don’t have the time to decide which of these options is better.
Template Literal Types
Typescript 4.1 里引入了字符串模板类型,简单的看下:
type Color = "red" | "blue";
type Quantity = "one" | "two";
type SeussFish = `${Quantity | Color} fish`;
// ^ = type SeussFish = "one fish" | "two fish" | "red fish" | "blue fish"
我们也可以举个实际一点的栗子,比如要传入一些位移参数:
type VerticalAlignment = "top" | "middle" | "bottom"
type HorizontalAlignment = "left" | "center" | "right"
// Takes
// | "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"); // works!
setAlignment("top-middel"); // error!
// 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"'.
再看个稍微复杂点的栗子:
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",
});
// 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");
}
})
Key Remapping in Mapped Types
映射类型可以基于任意键创建新的对象类型,打个比方:
type Options = {
[K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean
};
// same as
// type Options = {
// noImplicitAny?: boolean,
// strictNullChecks?: boolean,
// strictFunctionTypes?: boolean
// };
但是我们目前能够做的只是在给出的值里面去生成 key,而不能映射到其他 key 值,Typescript 4.1 通过 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>
// ^ = type LazyPerson = {
// getName: () => string;
// getAge: () => number;
// getLocation: () => string;
// }
我们还可以利用这个实现 Omit 的效果:
// Remove the 'kind' property
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, "kind">]: T[K]
};
interface Circle {
kind: "circle"
radius: number
}
type KindlessCircle = RemoveKindField<Circle>;
// ^ = type KindlessCircle = {
// radius: number
// }
问题收集
union type with filter / map
对于一个变量是联合数组类型(union type)时,无法调用其 filter 或者 map 方法:
const person: string[] | number[] = []
person.filter((p: string | number) => v !== null)
很显然 person.filter 肯定是可调用方法,但是 TS 对于这种情况的提示却是 The expression is not callable。解决办法是可以把数组类型由联合数组变为数组元素为联合类型,如下。推荐阅读:TypeScript 3.3。
const person: <string| number>[] = []
Operands for delete must be optional
当使用 delete 操作符,运算元必须为 any, unknown, never
,或者为可选(因为其类型中包含了 undefined)。否则会报错:
interface Thing {
prop: string
}
function f(x: Thing) {
delete x.prop // The operand of a 'delete' operator must be optional.
}
修改的方式也很简单,只要把相关属性改为 optional 即可:
interface Thing {
prop?: string
}
如果碰到比较复杂的栗子,比如接口定义了多个属性的话,还可以用以下自定义类型去修改:
export type TPickPartial<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & Partial<Pick<T, K>>
// 例如
type TTest = TPickPartial<TAnotherTest, 'canSort' | 'scenes'>
Object.keys using numbers
调用 Object.keys 获取对象的 key 值,在类型检测时默认为 string[]
,这样就会有个问题:
type Foo = { [key: number]: string }
const foo: Foo = { 100: 'foo', 200: 'bar' }
const sizes: number[] = Object.keys(foo) // Type 'string[]' is not assignable to type 'number[]
因此我们必须将字符串类型先转换为数字,最简单的方法是通过 map 来实现:
const sizes: number[] = Object.keys(foo).map(Number)
来看个完整一点的栗子:
const weekMapping = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
}
type TWeekKey = keyof typeof weekMapping
const keys = Object.keys(weekMapping).map(Number) as Array<TWeekKey>
function WeekSelector() {
return (
keys.map(key => (
<Button key={key}>{weekMapping[key]}</Button>
))
)
}
分布式条件类型 isDistributive
这节主要参考这里,先看看以下 res 的类型都是啥:
// 场景一
type TTest<T> = T extends number ? 1 : 2
type res = TTest<1 | 'a'>
// 场景二
type TTest<T> = T extends true ? 1 : 2
type res = TTest<boolean>
// 场景三
type TTest<T> = T extends true ? 1 : 2
type res = TTest<any>
// 场景四
type TTest<T> = T extends true ? 1 : 2
type res = TTest<never>
问题解答如下:
<!-- 场景一 -->
答案:1 | 2
原因:对于分布式条件类型,联合类型的每个类型会单独传入求值,把每个的结果合并成联合类型
<!-- 场景二 -->
答案:1 | 2
原因:boolean 其实也是联合类型,所以会把 true 和 false 分别传入求值,同上
<!-- 场景三 -->
答案:1 | 2
原因:条件类型对 any 做了特殊处理,如果左边是 any,那么直接把 trueType 和 falseType 合并成联合类型返回
<!-- 场景四 -->
答案:never
原因:当条件类型左边是 never 时,直接返回 never
我们根据源码来解释下,TypeScript 在处理到条件类型 Conditional Type 的时候,会设置一个 isDistributive 的属性,根据类型参数是不是 checkType(左边的类型)来设置:
function getTypeFromConditionalTypeNode(node: ConditionalTypeNode): Type {
const links = getNodeLinks(node);
if (!links.resolvedType) {
const checkType = getTypeFromTypeNode(node.checkType);
const aliasSymbol = getAliasSymbolForTypeNode(node);
const aliasTypeArguments = getTypeArgumentsForAliasSymbol(aliasSymbol);
const allOuterTypeParameters = getOuterTypeParameters(node, /*includeThisTypes*/ true);
const outerTypeParameters = aliasTypeArguments ? allOuterTypeParameters : filter(allOuterTypeParameters, tp => isTypeParameterPossiblyReferenced(tp, node));
const root: ConditionalRoot = {
node,
checkType,
extendsType: getTypeFromTypeNode(node.extendsType),
isDistributive: !!(checkType.flags & TypeFlags.TypeParameter),
inferTypeParameters: getInferTypeParameters(node),
outerTypeParameters,
instantiations: undefined,
aliasSymbol,
aliasTypeArguments
};
links.resolvedType = getConditionalType(root, /*mapper*/ undefined);
if (outerTypeParameters) {
root.instantiations = new Map<string, Type>();
root.instantiations.set(getTypeListId(outerTypeParameters), links.resolvedType);
}
}
return links.resolvedType;
}
因为 T extends number 的 checkType 是 T,所以这里的 isDistributive 就是 true,也就是它是分布式条件类型。这时会在求值的时候把每个类型单独传入求值,最后把结果合并。这就是分布式条件类型遇到联合类型时的处理:
// Distributive conditional types are distributed over union types. For example, when the
// distributive conditional type T extends U ? X : Y is instantiated with A | B for T, the
// result is (A extends U ? X : Y) | (B extends U ? X : Y). 👈
result = distributionType && checkType !== distributionType && distributionType.flags & (TypeFlags.Union | TypeFlags.Never) ?
mapTypeWithAlias(getReducedType(distributionType), t => getConditionalType(root, prependTypeMapping(checkType, t, newMapper)), aliasSymbol, aliasTypeArguments) :
getConditionalType(root, newMapper, aliasSymbol, aliasTypeArguments);
场景一和二都会走 mapTypeWithAlias 这个方法。场景三的 any 会发现走了 getConditionalType 这个方法,也确定了 any 不属于 union 联合类型,继续往下看,意思就是返回 trueType 和 falseType 的联合类型,因为 any 匹配任何类型,也就得出最终答案 1 | 2:
// Return union of trueType and falseType for 'any' since it matches anything
if (checkType.flags & TypeFlags.Any && !isUnwrapped) {
(extraTypes || (extraTypes = [])).push(instantiateType(getTypeFromTypeNode(root.node.trueType), combinedMapper || mapper));
}
至于 never 我们可以看到往下走了这段代码,直接返回 never 了:
if (type.flags & TypeFlags.Never) {
return type;
}
总结一下:条件类型当 checkType(左边的类型)是类型参数的时候,会有 distributive 的性质,也就是传入联合类型时会把每个类型单独传入做计算,最后把结果合并返回。这叫做分布式条件类型。
具体源码的解释请参考这里 👈
infer 类型推断
infer 表示在 extends 条件语句中待推断的类型变量:
type ParamType<T> = T extends (...args: infer P) => any ? P : T;
interface User {
name: string;
age: number;
}
type Func = (user: User) => void;
type Param = ParamType<Func>; // Param = User
type AA = ParamType<string>; // string
在 2.8 版本中,TypeScript 内置了一些与 infer 有关的映射类型:
type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any
相比于文章开始给出的示例,ReturnType<T>
只是将 infer P 从参数位置移动到返回值位置,因此此时 P 即是表示待推断的返回值类型:
type Func = () => User;
type Test = ReturnType<Func>; // Test = User
同样的,一个构造函数可以使用 new 来实例化,因此它的类型通常表示如下:
type Constructor = new (...args: any[]) => any
当 infer 用于构造函数类型中,可用于参数位置 new (...args: infer P) => any
和返回值位置 new (...args: any[]) => infer P
:
// 获取参数类型
type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any
? P
: never;
// 获取实例类型
type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;
class TestClass {
constructor(public name: string, public age: number) {}
}
type Params = ConstructorParameters<typeof TestClass>; // [string, number]
type Instance = InstanceType<typeof TestClass>; // TestClass
此外可参考 weekly 和 coditional infer 👈