类型收窄
试想我们有这样一个函数,函数名为padLeft:
function padLeft(padding: number | string, input: string): string {
throw new Error("Not implemented yet!");
}
该函数实现的功能是:如果参数padding是一个数字,我们就在input前面添加同等数量的空格,而如果padding是一个字符串,我们就直接添加到input前面。
让我们实现一下这个逻辑:
function padLeft(padding: number | string, input: string) {
return new Array(padding + 1).join(" ") + input;
// Operator '+' cannot be applied to types 'string | number' and 'number'.
}
如果这样写的话,编辑器里padding + 1这个地方就会标红,显示一个错误。
这是 TypeScript 在警告我们,如果把一个number类型(即例子里的数字 1)和一个number | string类型相加,也许并不会达到我们想要的结果。换句话说,我们应该先检查下padding是否是一个number,或者处理下当padding是string的情况,那我们可以这样做:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}
这个代码看上去也许没有什么有意思的地方,但实际上,TypeScript 在背后做了很多东西。
TypeScript 要学着分析这些使用了静态类型的值在运行时的具体类型。目前 TypeScript 已经实现了比如if/else、三元运算符、循环、真值检查等情况下的类型分析。
在我们的if语句中,TypeScript 会认为typeof padding ==="number"是一种特殊形式的代码,我们称之为类型守卫(type guard),TypeScript 会沿着执行时可能的路径,分析值在给定的位置上最具体的类型。
TypeScript 的类型检查器会分析这些类型守卫和赋值语句,而将类型推导为更精确类型的过程,我们称之为收窄(narrowing)。
typeof 类型守卫(type guards)
JavaScript 本身就提供了typeof操作符,可以返回运行时一个值的基本类型信息,会返回如下这些特定的字符串:
- "string"
- "number"
- "bigInt"
- "boolean"
- "symbol"
- "undefined"
- "object"
- "function"
typeof操作符在很多 JavaScript 库中都有着广泛的应用,而 TypeScript 已经可以做到理解并在不同的分支中将类型收窄。
在 TypeScript 中,检查typeof返回的值就是一种类型守卫。TypeScript 知道typeof不同值的结果,它也能识别 JavaScript 中一些怪异的地方,就比如在上面的列表中,typeof并没有返回字符串null,看下面这个例子:
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) {
// Object is possibly 'null'.
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
在这个printAll函数中,我们尝试判断strs是否是一个对象,原本的目的是判断它是否是一个数组类型,但是在 JavaScript 中,typeof null也会返回object。而这是 JavaScript 一个不幸的历史事故。
熟练的用户自然不会感到惊讶,但也并不是所有人都如此熟练。不过幸运的是,TypeScript 会让我们知道strs被收窄为strings[]| null,而不仅仅是string[]。
真值收窄(Truthiness narrowing)
在 JavaScript 中,我们可以在条件语句中使用任何表达式,比如&&、||、!等,举个例子,像if语句就不需要条件的结果总是boolean类型
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
这是因为 JavaScript 会做隐式类型转换,像0、NaN、""、0n、nullundefined这些值都会被转为false,其他的值则会被转为true。
当然你也可以使用Boolean函数强制转为boolean值,或者使用更加简短的!!:
// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true
这种使用方式非常流行,尤其适用于防范null和undefiend这种值的时候。举个例子,我们可以在printAll函数中这样使用:
function printAll(strs: string | string[] | null) {
if (strs && typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
可以看到通过这种方式,成功的去除了错误。
但还是要注意,在基本类型上的真值检查很容易导致错误,比如,如果我们这样写printAll函数:
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// DON'T DO THIS!
// KEEP READING
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
我们把原本函数体的内容包裹在一个if(strs)真值检查里,这里有一个问题,就是我们无法正确处理空字符串的情况。如果传入的是空字符串,真值检查判断为false,就会进入错误的处理分支。
如果你不熟悉 JavaScript ,你应该注意这种情况。
另外一个通过真值检查收窄类型的方式是通过!操作符。
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
// (parameter) values: undefined
} else {
return values.map((x) => x * factor);
// (parameter) values: number[]
}
}
等值收窄(Equality narrowing)
Typescript 也会使用switch语句和等值检查比如===!====!=去收窄类型。比如:
function example(x: string | number, y: string | boolean) {
if (x === y) {
// We can now call any 'string' method on 'x' or 'y'.
x.toUpperCase();
(method) String.toUpperCase(): string
y.toLowerCase();
(method) String.toLowerCase(): string
} else {
console.log(x);
(parameter) x: string | number
console.log(y);
(parameter) y: string | boolean
}
}
在这个例子中,我们判断了x和y是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而string类型就是x和y唯一可能的相同类型。所以在第一个分支里,x和y就一定是string类型。
判断具体的字面量值也能让 TypeScript 正确的判断类型。在上一节真值收窄中,我们写下了一个没有正确处理空字符串情况的printAll函数,现在我们可以使用一个更具体的判断来排除掉null的情况:
function printAll(strs: string | string[] | null) {
if (strs !== null) {
if (typeof strs === "object") {
for (const s of strs) {
(parameter) strs: string[]
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
(parameter) strs: string
}
}
}
JavaScript 的宽松相等操作符如==和!=也可以正确的收窄。在 JavaScript 中,通过== null这种方式并不能准确的判断出这个值就是null,它也有可能是undefined。对== undefined也是一样,不过利用这点,我们可以方便的判断一个值既不是null也不是undefined:
interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
// Remove both 'null' and 'undefined' from the type.
if (container.value != null) {
console.log(container.value);
(property) Container.value: number
// Now we can safely multiply 'container.value'.
container.value *= factor;
}
}
in 操作符收窄
JavaScript 中有一个in操作符可以判断一个对象是否有对应的属性名。TypeScript 也可以通过这个收窄类型。
举个例子,在"value" in x中,"value"是一个字符串字面量,而x是一个联合类型:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
// (parameter) animal: Fish
}
return animal.fly();
// (parameter) animal: Bird
}
通过"swim" in animal,我们可以准确的进行类型收窄。
而如果有可选属性,比如一个人类既可以swim也可以fly(借助装备),也能正确的显示出来:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
animal; // (parameter) animal: Fish | Human
} else {
animal; // (parameter) animal: Bird | Human
}
}
instanceof 收窄
instanceof也是一种类型守卫,TypeScript 也可以通过识别instanceof正确的类型收窄:
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
(parameter) x: Date
} else {
console.log(x.toUpperCase());
(parameter) x: string
}
}
赋值语句(Assignments)
TypeScript 可以根据赋值语句的右值,正确的收窄左值。
let x = Math.random() < 0.5 ? 10 : "hello world!"; let x: string | number x = 1; console.log(x); let x: number x = "goodbye!"; console.log(x); let x: string
注意这些赋值语句都有有效的,即便我们已经将x改为number类型,但我们依然可以将其更改为string类型,这是因为x最初的声明为string | number,赋值的时候只会根据正式的声明进行核对。
所以如果我们把x赋值给一个 boolean 类型,就会报错:
let x = Math.random() < 0.5 ? 10 : "hello world!"; let x: string | number x = 1; console.log(x); let x: number x = true; Type 'boolean' is not assignable to type 'string | number'. console.log(x); let x: string | number
控制流分析(Control flow analysis)
至此我们已经讲了 TypeScript 中一些基础的收窄类型的例子,现在我们看看在ifwhile等条件控制语句中的类型守卫,举个例子:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}
在第一个if语句里,因为有return语句,TypeScript 就能通过代码分析,判断出在剩余的部分return padding + input,如果 padding 是number类型,是无法达到(unreachable)这里的,所以在剩余的部分,就会将number类型从number | string类型中删除掉。
这种基于可达性(reachability)的代码分析就叫做控制流分析(control flow analysis)。在遇到类型守卫和赋值语句的时候,TypeScript 就是使用这样的方式收窄类型。而使用这种方式,一个变量可以被观察到变为不同的类型:
function example() {
let x: string | number | boolean;
x = Math.random() < 0.5; console.log(x); let x: boolean
if (Math.random() < 0.5) { x = "hello"; console.log(x); let x: string
} else {
x = 100;
console.log(x);
let x: number
}
return x;
let x: string | number
}
类型判断式(type predicates)
在有的文档里,type predicates会被翻译为类型谓词。考虑到 predicate 作为动词还有表明、声明、断言的意思,区分于类型断言(Type Assertion),这里我就索性翻译成类型判断式。
所谓predicate就是一个返回boolean值的函数。那我们接着往下看。
如果你想直接通过代码控制类型的改变,你可以自定义一个类型守卫。实现方式是定义一个函数,这个函数返回的类型是类型判断式,示例如下:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
在这个例子中,pet is Fish就是我们的类型判断式,一个类型判断式采用parameterName is Type的形式,但parameterName必须是当前函数的参数名。
当 isFish 被传入变量进行调用,TypeScript 就可以将这个变量收窄到更具体的类型:
// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim(); // let pet: Fish
} else {
pet.fly(); // let pet: Bird
}
注意这里,TypeScript 并不仅仅知道if语句里的pet是Fish类型,也知道在else分支里,pet是Bird类型,毕竟pet就两个可能的类型。
你也可以用isFish在Fish | Bird的数组中,筛选获取只有Fish类型的数组:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
// 在更复杂的例子中,判断式可能需要重复写
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
**(双星号)运算符,是一个幂运算,等同于Math.pow()。2 ** 102 的 10 次方,计算结果等于 1024。等价于Math.pow(2, 10)
可辨别联合(Discriminated unions)
让我们试想有这样一个处理Shape(比如Circles、Squares)的函数,Circles会记录它的半径属性,Squares会记录它的边长属性,我们使用一个kind字段来区分判断处理的是Circles还是Squares,这是初始的Shape定义:
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
注意这里我们使用了一个联合类型,"circle"|"square",使用这种方式,而不是一个string,我们可以避免一些拼写错误的情况:
function handleShape(shape: Shape) {
// oops!
if (shape.kind === "rect") {
// This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
// ...
}
}
现在我们写一个获取面积的getArea函数,而圆和正方形的计算面积的方式有所不同,我们先处理一下是Circle的情况:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2; // 圆的面积公式 S=πr²
// Object is possibly 'undefined'.
}
在strictNullChecks模式下,TypeScript 会报错,毕竟radius的值确实可能是undefined,那如果我们根据kind判断一下呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// Object is possibly 'undefined'.
}
}
你会发现,TypeScript 依然在报错,即便我们判断kind是circle的情况,但由于radius是一个可选属性,TypeScript 依然会认为radius可能是undefined。
我们可以尝试用一个非空断言(non-null assertion),即在shape.radius加一个!来表示radius是一定存在的。
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
但这并不是一个好方法,我们不得不用一个非空断言来让类型检查器确信此时shape.raidus是存在的,我们在 radius 定义的时候将其设为可选属性,但又在这里将其认为一定存在,前后语义也是不符合的。所以让我们想想如何才能更好的定义。
此时Shape的问题在于类型检查器并没有方法根据kind属性判断radius和sideLength属性是否存在,而这点正是我们需要告诉类型检查器的,所以我们可以这样定义Shape:
interface Circle { kind: "circle"; radius: number; }interface Square { kind: "square"; sideLength: number; }type Shape = Circle | Square;
在这里,我们把Shape根据kind属性分成两个不同的类型,radius和sideLength在各自的类型中被定义为required。
让我们看看如果直接获取radius会发生什么?
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
Property 'radius' does not exist on type 'Shape'.
Property 'radius' does not exist on type 'Square'.
}
就像我们第一次定义Shape那样,依然有错误。
当最一开始定义radius是optional的时候,我们会得到一个报错(strickNullChecks模式下),因为 TypeScript 并不能判断出这个属性是一定存在的。
而现在报错,是因为Shape是一个联合类型,TypeScript 可以识别出shape也可能是一个Square,而Square并没有radius,所以会报错。
但这时我们再根据kind属性检查一次呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
(parameter) shape: Circle
}
}
你会发现,报错就这样被去除了。
当联合类型中的每个类型,都包含了一个共同的字面量类型的属性,TypeScript 就会认为这是一个可辨别联合(discriminated union),然后可以将具体成员的类型进行收窄。
在这个例子中,kind就是这个公共的属性(作为 Shape 的可辨别(discriminant)属性)。
这也适用于switch语句:
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
(parameter) shape: Circle
case "square":
return shape.sideLength ** 2;
(parameter) shape: Square
}
}
这里的关键就在于如何定义Shape,告诉 TypeScript,Circle和Square是根据kind字段彻底分开的两个类型。这样,类型系统就可以在switch语句的每个分支里推导出正确的类型。
可辨别联合的应用远不止这些,比如消息模式,比如客户端服务端的交互、又比如在状态管理框架中,都是很实用的。
试想在消息模式中,我们会监听和发送不同的事件,这些都是以名字进行区分,不同的事件还会携带不同的数据,这就应用到了可辨别联合。客户端与服务端的交互、状态管理,都是类似的。
never 类型
当进行收窄的时候,如果你把所有可能的类型都穷尽了,TypeScript 会使用一个never类型来表示一个不可能存在的状态。
让我们接着往下看。
穷尽检查(Exhaustiveness checking)
never 类型可以赋值给任何类型,然而,没有类型可以赋值给never(除了never自身)。这就意味着你可以在switch语句中使用never来做一个穷尽检查。
举个例子,给getArea函数添加一个default项,把shape赋值给never类型,当出现还没有处理的分支情况时,never就会发挥作用。
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
当我们给Shape类型添加一个新成员,却没有做对应处理的时候,就会导致一个 TypeScript 错误:
interface Triangle {
kind: "triangle";
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}
因为 TypeScript 的收窄特性,执行到default的时候,类型被收窄为Triangle,但因为任何类型都不能赋值给never类型,这就会产生一个编译错误。通过这种方式,你就可以确保getArea函数总是穷尽了所有shape的可能性。
