百科狗-知识改变命运!
--

类型收窄 - TypeScript 标注类型

梵高1年前 (2023-11-21)阅读数 25#技术干货
文章标签类型

类型收窄

试想我们有这样一个函数,函数名为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() 

注意这些赋值语句都有有效的,即便我们已经将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 中一些基础的收窄类型的例子,现在我们看看在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() 


类型判断式(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 标注类型

你会发现,报错就这样被去除了。

当联合类型中的每个类型,都包含了一个共同的字面量类型的属性,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的可能性。

鹏仔微信 15129739599 鹏仔QQ344225443 鹏仔前端 pjxi.com 共享博客 sharedbk.com

免责声明:我们致力于保护作者版权,注重分享,当前被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理!邮箱:344225443@qq.com)

图片声明:本站部分配图来自网络。本站只作为美观性配图使用,无任何非法侵犯第三方意图,一切解释权归图片著作权方,本站不承担任何责任。如有恶意碰瓷者,必当奉陪到底严惩不贷!

内容声明:本文中引用的各种信息及资料(包括但不限于文字、数据、图表及超链接等)均来源于该信息及资料的相关主体(包括但不限于公司、媒体、协会等机构)的官方网站或公开发表的信息。部分内容参考包括:(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供参考使用,不准确地方联系删除处理!本站为非盈利性质站点,本着为中国教育事业出一份力,发布内容不收取任何费用也不接任何广告!)