• 首页
  • vue
  • TypeScript
  • JavaScript
  • scss
  • css3
  • html5
  • php
  • MySQL
  • redis
  • jQuery
  • 类型收窄

    试想我们有这样一个函数,函数名为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,或者处理下当paddingstring的情况,那我们可以这样做:

    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 会做隐式类型转换,像0NaN""0nnullundefined这些值都会被转为false,其他的值则会被转为true

    当然你也可以使用Boolean函数强制转为boolean值,或者使用更加简短的!!

    // both of these result in 'true'
    Boolean("hello"); // type: boolean, value: true
    !!"world"; // type: true, value: true
    

    这种使用方式非常流行,尤其适用于防范nullundefiend这种值的时候。举个例子,我们可以在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
      }
    }
    

    在这个例子中,我们判断了xy是否完全相等,如果完全相等,那他们的类型肯定也完全相等。而string类型就是xy唯一可能的相同类型。所以在第一个分支里,xy就一定是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语句里的petFish类型,也知道在else分支里,petBird类型,毕竟pet就两个可能的类型。

    你也可以用isFishFish | 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(比如CirclesSquares)的函数,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 依然在报错,即便我们判断kindcircle的情况,但由于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属性判断radiussideLength属性是否存在,而这点正是我们需要告诉类型检查器的,所以我们可以这样定义Shape:

    interface Circle {
      kind: "circle";
      radius: number;
    }
     
    interface Square {
      kind: "square";
      sideLength: number;
    }
     
    type Shape = Circle | Square;
    

    在这里,我们把Shape根据kind属性分成两个不同的类型,radiussideLength在各自的类型中被定义为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那样,依然有错误。

    当最一开始定义radiusoptional的时候,我们会得到一个报错(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,CircleSquare是根据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的可能性。

    上篇:类型推论

    下篇:类型兼容性