类型收窄 - TypeScript 标注类型
类型收窄
试想我们有这样一个函数,函数名为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
、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
函数中这样使用:
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()注意这些赋值语句都有有效的,即便我们已经将
x
改为number
类型,但我们依然可以将其更改为string
类型,这是因为x
最初的声明为string | number
,赋值的时候只会根据正式的声明进行核对。所以如果我们把
x
赋值给一个 boolean 类型,就会报错:let x = Math.random() Type 'boolean' is not assignable to type 'string | number'. console.log(x); let x: string | number
控制流分析(Control flow analysis)
至此我们已经讲了 TypeScript 中一些基础的收窄类型的例子,现在我们看看在
if
while
等条件控制语句中的类型守卫,举个例子: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()
类型判断式(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 ** 10
2 的 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
的可能性。
鹏仔微信 15129739599 鹏仔QQ344225443 鹏仔前端 pjxi.com 共享博客 sharedbk.com
图片声明:本站部分配图来自网络。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!