本文并不完全遵循原文翻译,对部分内容自己也做了解释补充。
Narrowing
试想我们有这样一个函数,函数名为 padLeft:
functionpadLeft(padding:number|string, input:string):string{thrownewError("Not implemented yet!");}
该函数实现的功能是:
如果参数 padding 是一个数字,我们就在 input 前面添加同等数量的空格,而如果 padding 是一个字符串,我们就直接添加到 input 前面。
让我们实现一下这个逻辑:
functionpadLeft(padding:number|string, input:string) {returnnewArray(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 的情况,那我们可以这样做:
functionpadLeft(padding:number|string, input:string) {if(typeofpadding ==="number") {returnnewArray(padding +1).join(" ") + input; }returnpadding + input;}
这个代码看上去也许没有什么有意思的地方,但实际上,TypeScript 在背后做了很多东西。
TypeScript 要学着分析这些使用了静态类型的值在运行时的具体类型。目前 TypeScript 已经实现了比如 if/else 、三元运算符、循环、真值检查等情况下的类型分析。
在我们的 if 语句中,TypeScript 会认为 typeof padding === number 是一种特殊形式的代码,我们称之为类型保护 (type guard),TypeScript 会沿着执行时可能的路径,分析值在给定的位置上最具体的类型。
TypeScript 的类型检查器会考虑到这些类型保护和赋值语句,而这个将类型推导为更精确类型的过程,我们称之为收窄 (narrowing)。 在编辑器中,我们可以观察到类型的改变:
从上图中可以看到在 if 语句中,和剩余的 return 语句中,padding 的类型都推导为更精确的类型。
接下来,我们就介绍 narrowing 所涉及的各种内容。
typeof 类型保护(type guards)
JavaScript 本身就提供了 typeof 操作符,可以返回运行时一个值的基本类型信息,会返回如下这些特定的字符串:
"string"
"number"
"bigInt"
"boolean"
"symbol"
"undefined"
"object"
"function"
typeof 操作符在很多 JavaScript 库中都有着广泛的应用,而 TypeScript 已经可以做到理解并在不同的分支中将类型收窄。
在 TypeScript 中,检查 typeof 返回的值就是一种类型保护。TypeScript 知道 typeof 不同值的结果,它也能识别 JavaScript 中一些怪异的地方,就比如在上面的列表中,typeof 并没有返回字符串 null,看下面这个例子:
functionprintAll(strs:string|string[] |null) {if(typeofstrs ==="object") {for(constsofstrs) {// Object is possibly 'null'.console.log(s); } }elseif(typeofstrs ==="string") {console.log(strs); }else{// do nothing}}
在这个 printAll 函数中,我们尝试判断 strs 是否是一个对象,原本的目的是判断它是否是一个数组类型,但是在 JavaScript 中,typeof null 也会返回 object。而这是 JavaScript 一个不幸的历史事故。
熟练的用户自然不会感到惊讶,但也并不是所有人都如此熟练。不过幸运的是,TypeScript 会让我们知道 strs 被收窄为 strings[] | null ,而不仅仅是 string[]。
真值收窄(Truthiness narrowing)
在 JavaScript 中,我们可以在条件语句中使用任何表达式,比如 && 、||、! 等,举个例子,像 if 语句就不需要条件的结果总是 boolean 类型
functiongetUsersOnlineMessage(numUsersOnline:number) {if(numUsersOnline) {return`There are${numUsersOnline}online now!`; }return"Nobody's here. :(";}
这是因为 JavaScript 会做隐式类型转换,像 0 、NaN、""、0n、null undefined 这些值都会被转为 false,其他的值则会被转为 true。
当然你也可以使用 Boolean 函数强制转为 boolean 值,或者使用更加简短的!!:
// both of these result in 'true'Boolean("hello");// type: boolean, value: true!!"world";// type: true, value: true
这种使用方式非常流行,尤其适用于防范 null和 undefiend 这种值的时候。举个例子,我们可以在 printAll 函数中这样使用:
functionprintAll(strs:string|string[] |null) {if(strs &&typeofstrs ==="object") {for(constsofstrs) {console.log(s); } }elseif(typeofstrs ==="string") {console.log(strs); }}
可以看到通过这种方式,成功的去除了错误。
https://zhuanlan.zhihu.com/p/432965911
https://zhuanlan.zhihu.com/p/432966586
https://zhuanlan.zhihu.com/p/432969442
https://zhuanlan.zhihu.com/p/432970841
https://zhuanlan.zhihu.com/p/432971528
https://zhuanlan.zhihu.com/p/432973353
但还是要注意,在基本类型上的真值检查很容易导致错误,比如,如果我们这样写 printAll 函数:
functionprintAll(strs:string|string[] |null) {// !!!!!!!!!!!!!!!!// DON'T DO THIS!// KEEP READING// !!!!!!!!!!!!!!!!if(strs) {if(typeofstrs ==="object") {for(constsofstrs) {console.log(s); } }elseif(typeofstrs ==="string") {console.log(strs); } }}
我们把原本函数体的内容包裹在一个 if (strs) 真值检查里,这里有一个问题,就是我们无法正确处理空字符串的情况。如果传入的是空字符串,真值检查判断为 false,就会进入错误的处理分支。
如果你不熟悉 JavaScript ,你应该注意这种情况。
另外一个通过真值检查收窄类型的方式是通过!操作符。
functionmultiplyAll(values:number[] |undefined, factor:number):number[] |undefined{if(!values) {returnvalues;// (parameter) values: undefined}else{returnvalues.map((x) =>x * factor);// (parameter) values: number[]}}
等值收窄(Equality narrowing)
Typescript 也会使用 switch 语句和等值检查比如 == !== == != 去收窄类型。比如:
在这个例子中,我们判断了 x 和 y 是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而 string 类型就是 x 和 y 唯一可能的相同类型。所以在第一个分支里,x 和 y 就一定是 string 类型。
判断具体的字面量值也能让 TypeScript 正确的判断类型。在上一节真值收窄中,我们写下了一个没有正确处理空字符串情况的 printAll 函数,现在我们可以使用一个更具体的判断来排除掉 null 的情况:
JavaScript 的宽松相等操作符如 == 和 != 也可以正确的收窄。在 JavaScript 中,通过 == null 这种方式并不能准确的判断出这个值就是 null,它也有可能是 undefined 。对 == undefined 也是一样,不过利用这点,我们可以方便的判断一个值既不是 null 也不是 undefined:
in 操作符收窄
JavaScript 中有一个 in 操作符可以判断一个对象是否有对应的属性名。TypeScript 也可以通过这个收窄类型。
举个例子,在 "value" in x 中,"value" 是一个字符串字面量,而 x 是一个联合类型:
typeFish= {swim:() =>void};typeBird= {fly:() =>void};functionmove(animal: Fish | Bird) {if("swim"inanimal) {returnanimal.swim();// (parameter) animal: Fish}returnanimal.fly();// (parameter) animal: Bird}
通过 "swim" in animal ,我们可以准确的进行类型收窄。
而如果有可选属性,比如一个人类既可以 swim 也可以 fly (借助装备),也能正确的显示出来:
typeFish= {swim:() =>void};typeBird= {fly:() =>void};typeHuman= { swim?:() =>void; fly?:() =>void};functionmove(animal: Fish | Bird | Human) {if("swim"inanimal) { animal;// (parameter) animal: Fish | Human}else{ animal;// (parameter) animal: Bird | Human}}
instanceof 收窄
instanceof 也是一种类型保护,TypeScript 也可以通过识别 instanceof 正确的类型收窄:
赋值语句(Assignments)
TypeScript 可以根据赋值语句的右值,正确的收窄左值。
注意这些赋值语句都有有效的,即便我们已经将 x 改为 number 类型,但我们依然可以将其更改为 string 类型,这是因为 x 最初的声明为 string | number,赋值的时候只会根据正式的声明进行核对。
所以如果我们把 x 赋值给一个 boolean 类型,就会报错:
控制流分析(Control flow analysis)
至此我们已经讲了 TypeScript 中一些基础的收窄类型的例子,现在我们看看在 if while等条件控制语句中的类型保护,举个例子:
functionpadLeft(padding:number|string, input:string) {if(typeofpadding ==="number") {returnnewArray(padding +1).join(" ") + input; }returnpadding + input;}
在第一个 if 语句里,因为有 return 语句,TypeScript 就能通过代码分析,判断出在剩余的部分 return padding + input ,如果 padding 是 number 类型,是无法达到 (unreachable) 这里的,所以在剩余的部分,就会将 number类型从 number | string 类型中删除掉。
这种基于可达性(reachability) 的代码分析就叫做控制流分析(control flow analysis)。在遇到类型保护和赋值语句的时候,TypeScript 就是使用这样的方式收窄类型。而使用这种方式,一个变量可以被观察到变为不同的类型:
类型判断式(type predicates)
在有的文档里, type predicates 会被翻译为类型谓词。考虑到 predicate 作为动词还有表明、声明、断言的意思,区分于类型断言(Type Assertion),这里我就索性翻译成类型判断式。
如果引用这段解释:
In mathematics, a predicate is commonly understood to be a Boolean-valued function_ P_: _X_→ {true, false}, called the predicate on _X_.
所谓 predicate 就是一个返回 boolean 值的函数。
那我们接着往下看。
如果你想直接通过代码控制类型的改变, 你可以自定义一个类型保护。实现方式是定义一个函数,这个函数返回的类型是类型判断式,示例如下:
functionisFish(pet: Fish | Bird): pet isFish{return(petasFish).swim!==undefined;}
在这个例子中,pet is Fish就是我们的类型判断式,一个类型判断式采用 parameterName is Type的形式,但 parameterName 必须是当前函数的参数名。
当 isFish 被传入变量进行调用,TypeScript 就可以将这个变量收窄到更具体的类型:
// Both calls to 'swim' and 'fly' are now okay.letpet =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 类型的数组:
constzoo: (Fish|Bird)[] = [getSmallPet(),getSmallPet(),getSmallPet()];constunderWater1:Fish[] = zoo.filter(isFish);// or, equivalentlyconstunderWater2:Fish[] = zoo.filter(isFish)asFish[];// 在更复杂的例子中,判断式可能需要重复写constunderWater3:Fish[] = zoo.filter((pet): pet isFish=> {if(pet.name==="sharkey")returnfalse;returnisFish(pet);});
可辨别联合(Discriminated unions)
让我们试想有这样一个处理 Shape (比如 Circles、Squares )的函数,Circles 会记录它的半径属性,Squares 会记录它的边长属性,我们使用一个 kind 字段来区分判断处理的是 Circles 还是 Squares,这是初始的 Shape 定义:
interfaceShape {kind:"circle"|"square"; radius?:number; sideLength?:number;}
注意这里我们使用了一个联合类型,"circle" | "square" ,使用这种方式,而不是一个 string,我们可以避免一些拼写错误的情况:
functionhandleShape(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 的情况:
functiongetArea(shape: Shape) {returnMath.PI* shape.radius**2;// 圆的面积公式 S=πr²// Object is possibly 'undefined'.}
在 strictNullChecks 模式下,TypeScript 会报错,毕竟 radius 的值确实可能是 undefined,那如果我们根据 kind 判断一下呢?
functiongetArea(shape: Shape) {if(shape.kind==="circle") {returnMath.PI* shape.radius**2;// Object is possibly 'undefined'.}}
你会发现,TypeScript 依然在报错,即便我们判断 kind 是 circle 的情况,但由于 radius 是一个可选属性,TypeScript 依然会认为 radius 可能是 undefined。
我们可以尝试用一个非空断言 (non-null assertion), 即在 shape.radius 加一个 ! 来表示 radius 是一定存在的。
functiongetArea(shape: Shape) {if(shape.kind==="circle") {returnMath.PI* shape.radius! **2; }}
但这并不是一个好方法,我们不得不用一个非空断言来让类型检查器确信此时 shape.raidus 是存在的,我们在 radius 定义的时候将其设为可选属性,但又在这里将其认为一定存在,前后语义也是不符合的。所以让我们想想如何才能更好的定义。
此时 Shape的问题在于类型检查器并没有方法根据 kind 属性判断 radius 和 sideLength 属性是否存在,而这点正是我们需要告诉类型检查器的,所以我们可以这样定义 Shape:
interfaceCircle {kind:"circle";radius:number;}interfaceSquare {kind:"square";sideLength:number;}typeShape=Circle|Square;
在这里,我们把 Shape 根据 kind 属性分成两个不同的类型,radius 和 sideLength 在各自的类型中被定义为 required。
https://zhuanlan.zhihu.com/p/432955990
https://zhuanlan.zhihu.com/p/432957536
https://zhuanlan.zhihu.com/p/432958420
https://zhuanlan.zhihu.com/p/432962330
https://zhuanlan.zhihu.com/p/432963856
让我们看看如果直接获取 radius 会发生什么?
functiongetArea(shape: Shape) {returnMath.PI* shape.radius**2;Property'radius'does not exist ontype'Shape'.Property'radius'does not exist ontype'Square'.}
就像我们第一次定义 Shape 那样,依然有错误。
当最一开始定义 radius 是 optional 的时候,我们会得到一个报错 (strickNullChecks 模式下),因为 TypeScript 并不能判断出这个属性是一定存在的。
而现在报错,是因为 Shape 是一个联合类型,TypeScript 可以识别出 shape 也可能是一个 Square,而 Square 并没有 radius,所以会报错。