前言
文中部分内容(typeof 、in、instanceof、类型谓词等)已经在TypeScript基础之类型保护介绍过。
文中内容都是官网/docs/handbook/2/narrowing.html 内容,以及参考
TypeScript 之 Narrowing—mqyqingfeng
类型收窄(Type Narrowing)
现在有一个名为padLeft的函数, 需要实现的功能是: 如果参数 padding 是一个数字,我们就在 input 前面添加同等数量的空格,而如果 padding 是一个字符串,我们就直接添加到 input 前面。
实现:
function padLeft(padding: number | string, input: string) {return "".repeat(padding) + input;// 类型“string | number”的参数不能赋给类型“number”的参数。// 不能将类型“string”分配给类型“number”。}
如果这样写的话,编辑器里padding
会提示类型“string | number”的参数不能赋给类型“number”的参数
错误。 提示我们应该先检查下padding
是否是一个number
。
所以下一步修改为:
function padLeft(padding: number | string, input: string) {if (typeof padding === "number") {return " ".repeat(padding) + input;}return padding + input;}
以上代码中 TypeScript 在背后做了很多东西。
TypeScript 要学着分析这些使用了静态类型的值在运行时的具体类型。目前 TypeScript 已经实现了比如 if/else 、三元运算符、循环、真值检查等情况下的类型分析。
在if
语句中,TypeScript 会认为typeof padding === number
是一种特殊形式的代码,我们称之为类型保护 (type guard)
,TypeScript 会沿着执行时可能的路径,分析值在给定的位置上最具体的类型。
TypeScript 的类型检查器会考虑到这些类型保护和赋值语句,而这个将类型推导为更精确类型的过程,我们称之为收窄 (narrowing)
。 在编辑器中,我们可以观察到类型的改变:
function padLeft(padding: number | string, input: string) {if (typeof padding === "number") {return " ".repeat(padding) + input;// (parameter) padding: number}return padding + input;// (parameter) padding: string}
从上图中可以看到在 if 语句中,和剩余的 return 语句中,padding 的类型都推导为更精确的类型。
接下来,我们就介绍 narrowing 所涉及的各种内容。
typeof 类型保护(type guards)
关于typeof
在之前文章里已经介绍过了,这里再介绍下。
在 TypeScript 中,检查typeof
返回的值就是一种类型保护。
function printAll(strs: string | string[] | null) {if (typeof strs === "object") {for (const s of strs) {// strs 提示 对象可能为 "null"。console.log(s);}} else if (typeof strs === "string") {console.log(strs);} else {// do nothing}}
在这个printAll
函数中,我们尝试判断strs
是否是一个对象,原本的目的是判断它是否是一个数组类型,但是在 JavaScript 中,typeof null
也会返回 object。
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、-0 、0n、NaN、null、undefined 和 false
这些值都会被转为false, 其他则会被转为true。
你也可以使用Boolean
函数强制转换为boolean
值, 或者使用!!
Boolean("hello"); // true!!"world";// true
真值收窄这种使用方式非常流行,尤其适用于防范 null和 undefiend 这种值的时候。
如:
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);}}
可以看到通过这种方式,成功的去除了错误。
但还是要注意,在基本类型上的真值检查很容易导致错误,比如:
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,就会进入错误的处理分支。
等值收窄(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(): stringy.toLowerCase();// (method) String.toLowerCase(): string} else {console.log(x);// (parameter) x: string | numberconsole.log(y);// (parameter) y: string | boolean}}
以上代码中,我们判断了 x 和 y 是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而 string 类型就是 x 和 y 唯一可能的相同类型。所以在第一个分支里,x 和 y 就一定是 string 类型。
判断具体的字面量值也能让 TypeScript 正确的判断类型。
我们知道:undefined == null
为true, 利用这点可以方便的判断一个值既不是null
也不是undefined
interface Container {value: number | null | undefined;}function multiplyValue(container: Container, factor: number) {// 排除调null 、undefinedif (container.value != null) {console.log(container.value); // (property) Container.value: number container.value *= factor;}}
in 操作符收窄
JavaScript 中有一个 in 操作符可以判断一个对象是否有对应的属性名。TypeScript 也可以通过这个收窄类型。 包括可选属性。
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}}
以上代码里,Human
里有swim、fly
两个可选属性, TS通过in
操作符可以准备的进行类型收窄。
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 | numberx = 1;console.log(x); // let x: numberx = "goodbye!"; console.log(x); // let x: string
以上赋值语句都有有效的,即便我们已经将 x 改为 number 类型,但我们依然可以将其更改为 string 类型,这是因为 x 最初的声明为 string | number,赋值的时候只会根据正式的声明进行核对。
所以如果我们把 x 赋值给一个 boolean 类型,就会报错
x = false;// 不能将类型“false”分配给类型“string | number”
控制流分析(Control flow analysis)
来看看在 if while等条件控制语句中的类型保护, 如:
function padLeft(padding: number | string, input: string) {if (typeof padding === "number") {return " ".repeat(padding) + input;}return padding + input;}
以上代码中, 在第一个if
语句里,因为有return
语句,TypeScript 就能通过代码分析,判断出在剩余的部分return padding + input
,如果padding
是number
类型,是无法达到(unreachable)
这里的,所以在剩余的部分,就会将number
类型从number | string
类型中删除掉。
这种基于可达性(reachability)
的代码分析就叫做控制流分析(control flow analysis)
。在遇到类型保护和赋值语句的时候,TypeScript 就是使用这样的方式收窄类型。
使用类型谓词(Using type predicates)
所谓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 就可以将这个变量收窄到更具体的类型:
class Fish {swim () {console.log('游泳~');}eat () {console.log('进食!');}}class Bird {fly () {console.log('飞翔~');}eat () {console.log('进食!');}}function getSmallPet(): Fish | Bird {return Math.random() > 0.5 ? new Fish() : new Bird()}let pet = getSmallPet();function isFish(pet: Fish | Bird): pet is Fish {return (pet as Fish).swim !== undefined;}if (isFish(pet)) {pet.swim();// let pet: Fish} else {pet.fly();// let pet: Bird}
可辨别联合(Discriminated unions)
interface Circle {kind: "circle"; // 字符串字面量类型radius: number;}interface Square {kind: "square"; // 字符串字面量类型sideLength: number;}type Shape = Circle | Square;function getArea(shape: Shape) {return Math.PI * shape.radius ** 2; // 类型“Shape”上不存在属性“radius”。// 类型“Square”上不存在属性“radius”}
报错,是因为Shape
是一个联合类型,TypeScript 可以识别出shape
也可能是一个Square
,而Square
并没有radius
,所以会报错。
通过判断字面量类型来进行区分:
function getArea (shape: Shape) {switch (shape.kind) {case "circle": // Circle类型return Math.PI * shape.radius ** 2;case "square": // Square类型return shape.sideLength ** 2;}}
当联合类型中的每个类型,都包含了一个共同的字面量类型的属性,TypeScript 就会认为这是一个可辨别联合(discriminated union)
,然后可以将具体成员的类型进行收窄。
在这个例子中,kind 就是这个公共的属性(作为 Shape 的可辨别(discriminant) 属性 )。
never 类型
当进行收窄的时候,如果你把所有可能的类型都穷尽了,TypeScript 会使用一个 never 类型来表示一个不可能存在的状态。
穷尽检查(Exhaustiveness checking)
never 类型可以赋值给任何类型,然而,没有类型可以赋值给 never (除了 never 自身)。这就意味着你可以在 switch 语句中使用 never 来做一个穷尽检查 .
接上面例子, 新添加一个新成员,却没有做对应处理的时候,就会导致一个 TypeScript 错误:
interface Triangle {kind: "triangle";sideLength: number;}type Shape = Circle | Square | Triangle;function getArea(shape: Shape) {switch (shape.kind) {case "circle": // Circle类型return Math.PI * shape.radius ** 2;case "square": // Circle类型return shape.sideLength ** 2;default:const _exhaustiveCheck: never = shape; // 不能将类型“Triangle”分配给类型“never”return _exhaustiveCheck;}}
因为 TypeScript 的收窄特性,执行到 default 的时候,类型被收窄为 Triangle,但因为任何类型都不能赋值给 never 类型,这就会产生一个编译错误。通过这种方式,你就可以确保 getArea 函数总是穷尽了所有 shape 的可能性。
参考资料
/docs/handbook/2/narrowing.html
TypeScript 之 Narrowing—mqyqingfeng