根据前面的内容可以知道,TypeScript 会使用静态类型来分析运行时的值;类似地,它也会在 JavaScript 运行控制流结构上加一层类型分析,如:if/else、三元条件运算符、循环、truthiness 检测等。
TypeScript 会循着程序可能的执行路径,查看赋值和类型守卫,从而分析出更精确的类型。这种提炼出更精确类型的过程叫做收窄(narrowing)。
function test(arg: number) {
if (typeof arg === "number") {
arg; // (parameter) arg: number
} else {
arg; // (parameter) arg: never
}
}
typeof 类型守卫
TypeScript 支持 JavaScript 中的 typeof 操作符,依 typeof 操作符的返回值所进行的类型检测是一种类型守卫。
function test(arg: number | string | boolean) {
if (typeof arg === "number") {
arg; // (parameter) arg: number
} else if (typeof arg === "string") {
arg; // (parameter) arg: string
} else {
arg; // (parameter) arg: boolean
}
}
truthiness 收窄
在 JavaScript 中,任何值都有一个布尔值与之对应,该布尔值也就是相应的 truthiness。
对应规则如下:0、NaN、''、0n、null、undefined、false 对应 false;其它值均对应 true。
function test(arg: "string" | null) {
if (arg) {
arg; // (parameter) arg: "string"
} else {
arg; // (parameter) arg: null
}
}
Boolean 和 !! 都可以实现值的 truthiness 转换。在 TypeScript 中,Boolean 函数和 !! 的返回值都会被推断为 boolean 类型,但是,当被作用值的形式比较简单时,!! 的返回值可以推断为更精确的布尔型字面量类型。
const a = Boolean(0); // const a: boolean
const b = !!0; // const b: false
const c = !!{ num: 1 }; // const c: true
const d = !!(0 + 1); // const d: boolean
注意:
!!{}的返回值无法推断出最精确的类型true,因为空对象类型{}是其它所有对象类型以及非null/undefined原始类型的父类型;之所以是这些原始类型的父类型,可能是因为这些原始类型都有相应的包装对象类型。const e = !!{}; // const e: boolean let empty = {}; // let empty: {} empty = 1; empty = ""; empty = true; empty = Symbol(0); empty = BigInt(0n); // No error const f = !!({} as object); // const f: true上面最后一行代码,通过断言让 TypeScript 知道
{}表示的是对象而非原始类型,从而把!!{}推断为更精确的布尔型字面量类型true。
使用 truthiness 可以方便地对 null、undefined 之类的值进行守卫,但是对于原始类型使用 truthiness 检测可能会带来一些不易察觉的错误,如:'' 的 truthiness 也是 false。
等式收窄
对于 switch 语句以及 ===、!==、==、!= 之类的等式检测,不管参与相等比较的是变量,还是字面量,TypeScript 均支持类型收窄。
function test(a: string | number, b?: string | boolean | null) {
if (a === b) {
a; // (parameter) a: string
b; // (parameter) b: string
} else {
a; // (parameter) a: string | number
b; // (parameter) b: string | boolean | null | undefined
if (b !== null) {
b; // (parameter) b: string | boolean | undefined
}
if (b != null) {
b; // (parameter) b: string | boolean
}
}
}
let a = 0; // let a: number
switch (a) {
case 1:
a; // let a: 1
break;
case 2:
a; // let a: 2
case 3:
a; // let a: 2 | 3
break;
default:
a; // let a: number
break;
}
in 操作符收窄
TypeScript 支持 in 操作符,并将其作为类型收窄的一种方式。但是,in 操作符与非联合对象类型一起使用时,无法收窄类型。
type Fish = { swim: number };
type Bird = { fly: string };
type Human = { swim?: number; fly?: string };
function test() {
/* union type */
let animal = { swim: 1 } as Fish | Bird;
if ("swim" in animal) {
// let animal: Fish | Bird
animal; // let animal: Fish
} else {
animal; // let animal: Bird
}
if ("run" in animal) {
animal; // let animal: never
}
animal = { swim: 1 };
if ("swim" in animal) {
// let animal: Fish
animal; // let animal: Fish
} else {
animal; // let animal: never
}
/* nonunion type */
let anotherAnimal: Fish = { swim: 1 };
if ("swim" in anotherAnimal) {
// let anotherAnimal: Fish
anotherAnimal; // let anotherAnimal: Fish
} else {
anotherAnimal; // let anotherAnimal: Fish
}
}
注意:拥有相应可选属性的对象类型将会同时出现在收窄的两个分支上。
function test2(animal: Fish | Bird | Human) { if ("swim" in animal) { // (parameter) animal: Fish | Bird | Human animal; // (parameter) animal: Fish | Human } else { animal; // (parameter) animal: Bird | Human } }
instanceof 收窄
instanceof 操作符也是一种类型守卫,可以对类型进行收窄。
function test(x: Date | string) {
if (x instanceof Date) {
x; // (parameter) x: Date
} else {
x; // (parameter) x: string
}
}
赋值
对于联合类型的变量,后续接收赋值时,TypeScript 会根据值的类型将变量收窄为相应的成员类型。TypeScript 允许对变量重复赋值,只要符合声明时的类型即可,即使前后所赋值的类型不同。
/* union type */
let a: number | string | boolean;
a = true;
a; // let a: true
a = 1;
a; // let a: number
a = "";
a; // let a: string
/* nonunion type */
let anyTest: any;
anyTest = 1;
anyTest; // let anyTest: any
let unknownTest: unknown;
unknownTest = 1;
unknownTest; // let unknownTest: unknown
上面的例子可以看出:赋值无法收窄非联合类型。而变量 a 可以收窄到类型 true 再次说明:boolean 类型就是联合类型 true | false 的别名。
控制流分析
条件语句对变量类型的限制使得,对于某些代码,只有某些类型的变量可以到达,这种行为被称为代码的可达性(reachability)。
基于可达性的代码分析被称为控制流分析。当遇到类型守卫或者赋值语句时,TypeScript 将使用控制流分析来收窄类型。在 TypeScript 分析变量时,控制流会反复地分离(split off)、重新合并(re-merge),该变量在不同地方也就有了不同类型。
let x: string | number | boolean;
x = 0.3;
x; // let x: number
/* split */
if (x > 0.5) {
x = "hello";
x; // let x: string
} else {
x = true;
x; // let x: true
}
/* re-merge */
x; // let x: string | true
上面代码的流程图如下,从中可以清晰地看出控制流的分离与合并:
graph TD
Start(Start)
Init["let x: string | number | boolean;
x = 0.3;
x; // let x: number"]
SplitOff{x > 0.5?}
TrueBranch["x = #quot;hello#quot;;
x; // let x: string"]
FalseBranch["x = true;
x; // let x: true"]
ReMerge["x; // let x: string | true"]
End(End)
Start --> Init --> SplitOff
SplitOff -->|yes| TrueBranch --> ReMerge
SplitOff -->|no| FalseBranch --> ReMerge
ReMerge --> End
使用类型谓词(predicates)
返回值类型为类型谓词的函数,可以实现自定义类型守卫。类型谓词的语法:parameterName is Type,其中 parameterName 是当前函数的某个参数名,而类型 Type 必须是该参数类型的子类型。类型谓词的作用范围并不是函数体内部,而是函数调用时返回值所决定的控制流。
类型谓词要求函数返回值必须是 boolean 类型。在返回值为 true 所对应的控制流中,类型谓词把对应 parameterName 的实参收窄为 Type 类型;在返回值为 false 所对应的控制流中,类型谓词把对应 parameterName 的实参类型推断为不是 Type。
type Fish = {
swim: () => void;
};
type Bird = {
fly: () => void;
};
type Dog = {
run: () => void;
};
function isFish(pet: Fish | Bird | Dog): pet is Fish {
return false;
}
function test(pet1: Fish | Bird, pet2: Bird | Dog) {
if (isFish(pet1)) {
pet1; // (parameter) pet1: Fish
} else {
pet1; // (parameter) pet1: Bird
}
if (isFish(pet2)) {
pet2; // (parameter) pet2: (Bird | Dog) & Fish
} else {
pet2; // (parameter) pet2: Bird | Dog
}
}
注意:这里只关心返回值为
true/false所对应的控制流,而不关心返回值是否真的可以为true/false。原因在于,函数返回值的计算可能很复杂,而 TypeScript 的目标只是在 JavaScript 执行前做静态类型检测。正因如此,使用类型谓词时,函数体内返回值的具体实现必须保持一致,即:当且仅当parameterName为Type类型时,函数才返回true。
上面的代码可以看到:
-
如果函数实参的类型中包含
Typetrue分支:实参类型收窄为Type;false分支:从实参类型中排除Type类型。 -
如果函数实参的类型中不包含
Typetrue分支:实参类型收窄为argumentType & Type;false分支:实参类型不变;
其中 argumentType 是实参类型。
在含有 &&、||、! 的条件表达式以及 ?: 三元运算符中,规则是类似的。只不过此时,true、false 所对应的控制流处于同一行代码的前后不同位置,控制流由逻辑短路的规则所决定。
function test(pet: Fish | Bird) {
isFish(pet) && pet; // (parameter) pet: Fish
isFish(pet) || pet; // (parameter) pet: Bird
!isFish(pet) && pet; // (parameter) pet: Bird
!isFish(pet) || pet; // (parameter) pet: Fish
isFish(pet)
? pet // (parameter) pet: Fish
: pet; // (parameter) pet: Bird
}
注意:目前
switch...case...语句中还没有相应的类型谓词收窄。function test(pet: Fish | Bird) { switch (isFish(pet)) { case true: pet; // (parameter) pet: Fish | Bird break; case false: pet; // (parameter) pet: Fish | Bird break; default: pet; // (parameter) pet: Fish | Bird break; } }
可区分(discriminated)联合类型
如果联合类型中的每个成员都包含一个共同的字面量类型属性,TypeScript 将会把它视为可区分联合类型,利用该属性可将联合类型收窄为某些成员。
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
sideLength: number;
};
type Shape = Circle | Square;
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // (parameter) shape: Circle
} else {
return shape.sideLength ** 2; // (parameter) shape: Square
}
}
可区分联合类型可用于 JavaScript 中任一种消息传递系统,如:通过网络传递消息,在状态管理框架中编写 mutations。
never 类型和穷尽性(exhaustiveness)检查
当变量类型被收窄到什么都没有时,TypeScript 将使用 never 类型来表示该变量类型,指的是不应该存在的状态。
never 类型可以赋值给任何类型,但是,能赋值给 never 类型的只有它自身。因此,将收窄和 never 类型结合起来可以进行穷尽性检查,也就是对某个值的所有可能情形进行全面检查。
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
sideLength: number;
};
type Triangle = {
kind: "triangle";
sideLength: number;
};
type Shape = Circle | Square;
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // (parameter) shape: Circle
} else if (shape.kind === "square") {
return shape.sideLength ** 2; // (parameter) shape: Square
} else {
const __exhaustiveCheck: never = shape; // (parameter) shape: never
return __exhaustiveCheck;
}
}