TypeScript学习文档二------类型缩小

四 类型缩小

假设我们有一个名为padLeft的函数

function padLeft(padding: number | string, input: string): string {
	throw new Error("尚未实现!");
}

我们来扩充一下功能: 如果paddingnumber, 它会将其视为我们将要添加到input的空格数; 如果paddingstring, 它只在input上做padding. 让我们尝试实现:

function padLeft(padding: number | string, input: string): string {
	return new Array(padding + 1).join(" ") + input;
}

这样的话, 我们在padding + 1处会遇到错误. TS警告我们, 运算符+不能应用于类型number | stringstring, 这个逻辑是对的, 因为我们没有明确检查padding是否为number, 也没有处理它是string的情况, 所以我们我们这样做:

function padLeft(padding: number | string, input: string): string {
	if (typeof padding === "number") {
    	return new Array(padding + 1).join(" ") + input;
    }
    return padding + input;

}

如果这大部分看起来像无趣的JavaScript代码,这也算是重点吧。除了我们设置的注解之外,这段 TypeScript代码看起来就像JavaScript。

我们的想法是,TypeScript的类型系统旨在使编写典型的 JavaScript代码变得尽可能容易,而不需要弯腰去获得类型安全。

虽然看起来不多,但实际上有很多价值在这里。就像TypeScript使用静态类型分析运行时的值一样,它在JavaScript的运行时控制流构造上叠加了类型分析,如if/else、条件三元组、循环、真实性检查等,这些都会影响到这些类型。

在我们的if检查中,TypeScript看到typeof padding ==="number",并将其理解为一种特殊形式的代码,称为类型保护TypeScript遵循我们的程序可能采取的执行路径,以分析一个值在特定位置的最具体的可能类型。它查看这些特殊的检查(称为类型防护)和赋值,将类型细化为比声明的更具体的类型的过程被称为缩小。在许多编辑器中,我们可以观察这些类型的变化,我们甚至会在我们的例子中这样做。

TypeScript 可以理解几种不同的缩小结构.

4.1 typeof类型守卫

正如我们所见, Js支持typeof运算符, 它可以提供有关我们在运行时拥有的值类型的非常基本的信息.

TS期望它返回一组特定的字符串:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

就像我们刚才在padLeft中看到的那样, 这个运算符经常出现在许多JavaScript库中, TS可以理解为, 它缩小在不同分支中的类型.

在TS中, 检查typeof的返回值是一种类型保护. 因为TS对typeof操作进行编码, 从而返回不同的值, 所以它知道对JS做了什么. 例如, 请注意上面的列表中, typeof 不返回null.

function printAll(strs: string | string[] | null) {
    if (typeof strs === "object") {
        for (const s of strs) {
        	console.log(s);
    	}
    } else if (typeof strs === "string") {
    	console.log(strs);
    } else {
    	// 做点事
    }
}

image-20220311230619295

printAll 函数中,我们尝试检查 strs 是否为对象,来代替检查它是否为数组类型(现在可能是强调数组是 JavaScript 中的对象类型的好时机)。但事实证明,在 JavaScript 中, typeof null 实际上也是 "object" ! 这是历史上的不幸事故之一。

有足够经验的用户可能不会感到惊讶,但并不是每个人都在 JavaScript 中遇到过这种情况;幸运的是, ts 让我们知道, strs 只缩小到 string[] | null ,而不仅仅是 string[].

这可能是我们所谓的“真实性”检查的一个很好的过渡.

4.2 真值缩小

真值检查是我们在JS中经常做的一件事. 在JS中, 我们可以在条件 && || if语句 布尔否定(!)等中使用任何表达式.

例如, if语句不希望它们的条件总是剧有类型boolean

function getUserOnlineMessage(numUserOnline: number) {
    if(numUserOnline) {
        return `现在共有 ${numUserOnline} 人在线!`
    }
    return "现在没有人在线:("
}

在JS总, if条件语句, 首先把他们的条件强制转化为boolean以使其有意义, 然后根据结果是true还是false来选择他们的分支. 像下面这些值都强制转换为false:

  • 0
  • NaN
  • "" (空字符串)
  • On (bigint 0的版本)
  • null
  • undefined

其他值被强制转化为true. 你始终可以在Boolean函数中运行值获得boolean, 或使用较短的双布尔否定将值强制转换为boolean.(后者的优点是ts推断出一个狭窄的文字布尔类型true, 而将第一个推断为boolean类型)

// 这两个结果都返回 true
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true

利用这个特性, 我们可以防范诸如nullundefined之类的值时. 例如, 让我们尝试将它用于我们的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);
    }
}

我们通过检查strs是否为真, 消除了上述错误. 这可以防止我们在运行代码的时候出现一些错误, 例如:

TypeError: null is not iterable

但请记住, 对原语的真值检查通常容易出错. 例如, 考虑改写printAll:

function printAll(strs: string | string[] | null) {
    // !!!!!!!!!!!!!!!!
    // 别这样!
    // 原因在下边
    // !!!!!!!!!!!!!!!!
    if (strs) {
        if (typeof strs === "object") {
            for (const s of strs) {
                console.log(s);
            }
        } else if (typeof strs === "string") {
        	console.log(strs);
        }
    }
}

我们将整个函数体包裹在一个真值检查中, 但是这有一个小小的缺点: 我们可能不再正确处理空字符串的情况.

TS在这里根本不会报错, 如果你不熟悉JS, 这是值得注意的. TS通常可以帮你及早发现错误, 但是如果你选择对某个值不做任何处理, 那么它可以做的就只有这么多, 而不会考虑过多逻辑方面的问题, 如果需要, 你可以确保linter(程序规范性)处理此类情况.

关于通过真实性缩小范围的最后一点,是通过布尔否定 ! 把逻辑从否定分支中过滤掉。

function multiplyAll(
    values: number[] | undefined,
    factor: number
): number[] | undefined {
    if (!values) {
    	return values;
    } else {
    	return values.map((x) => x * factor);
    }
}

4.3 等值缩小

ts也使用分支语句做=== !== ==!= 等值检查, 来实现类型缩小. 例如:

function example(x: string | number, y: string | boolean) {
    if (x === y) {
        // 现在可以在x,y上调用字符串类型的方法了
        x.toUpperCase();
        y.toLowerCase();
    } else {
        console.log(x);
        console.log(y);
    }
}

当我们在上面的示例中检查 x 和 y 是否相等时,TypeScript知道它们的类型也必须相等。由于 string 是 x 和 y 都可以采用的唯一常见类型,因此TypeScript 知道 x 、 y 如果都是 string ,则程序走第一个分支中 。

检查特定的字面量值(而不是变量)也有效。在我们关于真值缩小的部分中,我们编写了一个 printAll 容易出错的函数,因为它没有正确处理空字符串。相反,我们可以做一个特定的检查来阻止 null ,并且 TypeScript 仍然正确地从 strs 里移除 null 。

function printAll(strs: string | string[] | null) {
    if (strs !== null) {
        if (typeof strs === "object") {
            for (const s of strs) {
                console.log(s);
            }
        } else if (typeof strs === "string") {
            console.log(strs);
        }
    }
}

JavaScript 更宽松的相等性检查 ==!= ,也能被正确缩小。如果你不熟悉,如何检查某个变量是否 == null ,因为有时不仅要检查它是否是特定的值 null ,还要检查它是否可能是 undefined 。这同样适用 于 == undefined它检查一个值是否为 null 或 undefined 。现在你只需要这个 ==!= 就可以搞定了。

interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
    // 从类型中排除了undefined 和 null
    if (container.value != null) {
        console.log(container.value);
        // 现在我们可以安全地乘以“container.value”了
        container.value *= factor;
    }
}
    

console.log(multiplyValue({value: 5}, 5))
console.log(multiplyValue({value: null}, 5))
console.log(multiplyValue({value: undefined}, 5))
console.log(multiplyValue({value: '5'}, 5))

image-20220312115410324

4.4 in操作符缩小

JavaScript 有一个运算符,用于确定对象是否具有某个名称的属性: in 运算符。TypeScript 考虑到了这 一点,以此来缩小潜在类型的范围。 例如,使用代码: "value" in x 。这里的 "value" 是字符串文字, x 是联合类型。值为“true”的分支缩小,需要 x 具有可选或必需属性的类型的值;值为 “false” 的分支缩小,需要具有可选或缺失属性的类型的值。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
    if ("swim" in animal) {
    	return animal.swim();
    }
    return animal.fly();
}

另外,可选属性还将存在于缩小的两侧,例如,人类可以游泳和飞行(使用正确的设备),因此应该出 现在 in 检查的两侧:

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: Fish | Human
    	animal;
    } else {
    	// animal: Bird | Human
    	animal;
    }
}

4.5 instanceof操作符缩小

JS有一个运算符instanceof检查一个值是否是另一个值的“实例”。更具体地,在JavaScript 中 x instanceof Foo 检查 x 的原型链是否含有 Foo.prototype 。虽然我们不会在这里深入探讨,当 我们进入 类(class) 学习时,你会看到更多这样的内容,它们大多数可以使用 new 关键字实例化。 正如你可能已经猜到的那样, instanceof 也是一个类型保护,TypeScript 在由 instanceof 保护的分支中实现缩小。

function logValue(x: Date | string) {
    if (x instanceof Date) {
        console.log(x.toUTCString());
    } else {
        console.log(x.toUpperCase());
    }
}
logValue(new Date()) // Mon, 15 Nov 2021 22:34:37 GMT
logValue('hello ts') // HELLO TS

4.6 分配缩小

正如我们之前所提到的, 当我们为任何变脸赋值时, TS会检查赋值的右侧并适当缩小左侧.

// let x: string | number
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;

// let x: number
console.log(x);
x = "goodbye!";
// let x: string
console.log(x);

请注意,这些分配中的每一个都是有效的。即使在我们第一次赋值后观察到的类型 x 更改为 number , 我们仍然可以将 string 赋值给 x 。这是因为声明类型 x 开始是 string | number

如果我们分配了一个 boolean 给 x ,我们就会看到一个错误,因为它不是声明类型的一部分。

let x = Math.random() < 0.5 ? 10 : "hello world!";
// let x: string | number
x = 1;

// let x: number
console.log(x);

// 出错了~! 
x = true

// let x: string | number
console.log(x);

image-20220312123406176

4.7 控制流分析

到目前为止, 我们已经通过一些基本实例来说明TS如何在特定分支中缩小范围. 但是除了从每个变量中走出来, 并在if while 条件等中寻找类型保护之外, 还有更多的事情要做, 比如:

function padLeft(padding: number | string, input: string) {
    if (typeof padding === "number") {
    	return new Array(padding + 1).join(" ") + input;
    }
    return padding + input;
}

padLeft从其第一个if块中返回. TS能够分析这段代码,并看到在padding是数字的情况下, 主体的其余部分( return padding + input; )是不可达的。因此,它能够将数字从 padding 的类型中移除(从string|number缩小到string),用于该函数的其余部分。

这种基于可达性的代码分析被称为控制流分析,TypeScript使用这种流分析来缩小类型,因为它遇到了 类型守卫和赋值。当一个变量被分析时,控制流可以一次又一次地分裂和重新合并,该变量可以被观察到在每个点上有不同的类型.

function example() {
    let x: string | number | boolean;
    
    x = Math.random() < 0.5;
    
    // let x: boolean
    console.log(x);
    if (Math.random() < 0.5) {
        x = "hello";
        // let x: string
        console.log(x);
    } else {
        x = 100;
        // let x: number
        console.log(x);
    }
    // let x: string | number
    return x;
}
let x = example()
x = 'hello'
x = 100
x = true // error

image-20220312180252869

4.8 使用类型谓词

到目前为止,我们已经用现有的JavaScript结构来处理窄化问题,然而有时你想更直接地控制整个代码中的类型变化。

为了定义一个用户定义的类型保护,我们只需要定义一个函数,其返回类型是一个类型谓词.

type Fish = {
    name: string
    swim: () => void
}

type Bird = {
    name: string
    fly: () => void
}
function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined
}

在这个例子中, pet is Fish 是我们的类型谓词。谓词的形式是 parameterName is Type ,其中 parameterName 必须是当前函数签名中的参数名称

任何时候 isFish 被调用时,如果原始类型是兼容的,TypeScript将把该变量缩小到该特定类型。

function getSmallPet(): Fish | Bird {
    let fish: Fish = {
        name: 'gold fish',
        swim: () => {
            console.log('fish is swimming.')
    	}
    }
    let bird: Bird = {
        name: 'sparrow',
        fly: () => {
            console.log('bird is flying.')
        }
    }
    return Math.random() < 0.5 ? bird : fish
}
// 这里 pet 的 swim 和 fly 都可以访问了
let pet = getSmallPet() // 
console.log(pet)
if (isFish(pet)) {
    pet.swim()
} else {
    pet.fly() 
}

注意,TypeScript不仅知道 petif 分支中是一条鱼;它还知道在 else 分支中,你没有一条 Fish ,所以你一定有一只 Bird 。

你可以使用类型守卫 isFish 来过滤 Fish | Bird 的数组,获得 Fish 的数组。

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()]
const underWater1: Fish[] = zoo.filter(isFish)
console.log(underWater1)
// 或者,等同于
const underWater2: Fish[] = zoo.filter(isFish) as Fish[]
console.log(underWater2)

// 对于更复杂的例子,该谓词可能需要重复使用
const underWatch3: Fish[] = zoo.filter((pet): pet is Fish => {
    if (pet.name === 'frog') {
    	return false
    }
    return isFish(pet)
})

4.9 受歧视的unions

到目前为止,我们所看的大多数例子都是围绕着用简单的类型(如 stringbooleannumber )来缩小单个变量。虽然这很常见,但在JavaScript中,大多数时候我们要处理的是稍微复杂的结构。

为了激发灵感,让我们想象一下,我们正试图对圆形和方形等形状进行编码。圆记录了它们的半径,方记录了它们的边长。我们将使用一个叫做 kind 的字段来告诉我们正在处理的是哪种形状。这里是定义 Shape 的第一个尝试。

interface Shape {
    kind: "circle" | "square";
    radius?: number;
    sideLength?: number;
}

注意,我们使用的是字符串字面类型的联合。 "circle""square" 分别告诉我们应该把这个形状 当作一个圆形还是方形。通过使用 "circle" | "square " 而不是string ,我们可以避免拼写错误的问题。

function handleShape(shape: Shape) {
    // oops!
    if (shape.kind === "rect") {
    	// ...
    }
}

image-20220312213533490

我们可以编写一个 getArea 函数,根据它处理的是圆形还是方形来应用正确的逻辑。我们首先尝试处理圆形。

function getArea(shape: Shape) {
	return Math.PI * shape.radius ** 2;
}

image-20220312214029711

strictNullChecks下,这给了我们一个错误——这是很恰当的,因为radius可能没有被定义。 但是如果我们对kind属性进行适当的检查呢?

function getArea(shape: Shape) {
    if (shape.kind === "circle") {
    	return Math.PI * shape.radius ** 2;
    }
}

嗯, TypeScript 仍然不知道该怎么做。我们遇到了一个问题,即我们对我们的值比类型检查器知道的更 多。我们可以尝试使用一个非空的断言 ( radius 后面的那个叹号 ! ) 来说明 radius 肯定存在。

function getArea(shape: Shape) {
    if (shape.kind === "circle") {
    	return Math.PI * shape.radius! ** 2;
    }
}

但这感觉并不理想。我们不得不用那些非空的断言对类型检查器声明一个叹号(),以说服它相信 shape.radius 是被定义的,但是如果我们开始移动代码,这些断言就容易出错。此外,在 strictNullChecks 之外,我们也可以意外地访问这些字段(因为在读取这些字段时,可选属性被认为总是存在的)。我们绝对可以做得更好.

Shape 的这种编码的问题是,类型检查器没有办法根据种类属性知道 radiussideLength 是否存在。我们需要把我们知道的东西传达给类型检查器。考虑到这一点,让我们再来定义一下Shape.

interface Circle {
    kind: "circle";
    radius: number;
}

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

在这里,我们正确地将 Shape 分成了两种类型,为 kind 属性设置了不同的值,但是 radiussideLength 在它们各自的类型中被声明为必需的属性。

让我们看看当我们试图访问 Shape 的半径时会发生什么。

function getArea(shape: Shape) {
	return Math.PI * shape.radius ** 2;
}

image-20220312215150736

就像我们对 Shape 的第一个定义一样,这仍然是一个错误。当半径是可选的时候,我们得到了一个错误(仅在 strictNullChecks 中),因为 TypeScript 无法判断该属性是否存在。现在 Shape 是一个联合体,TypeScript 告诉我们 shape 可能是一个 Square ,而Square并没有定义半径 radius 。 这两种解释都是正确的,但只有我们对 Shape 的新编码仍然在 strictNullChecks 之外导致错误.

但是, 如果我们在此尝试检查kind属性呢?

function getArea(shape: Shape) {
    if (shape.kind === "circle") {
        // shape: Circle
        return Math.PI * shape.radius ** 2;
    }
}

这就摆脱了错误! 当 union 中的每个类型都包含一个与字面类型相同的属性时,TypeScript 认为这是一 个有区别的 union ,并且可以缩小 union 的成员。

在这种情况下, kind 就是那个共同属性(这就是 Shape 的判别属性)。检查 kind 属性是否为 "circle" ,就可以剔除 Shape 中所有没有 "circle" 类型属性的类型。这就把 Shape 的范围缩小到 了 Circle 这个类型。

同样的检查方法也适用于 switch 语句。现在我们可以试着编写完整的 getArea ,而不需要任何讨厌 的叹号 ! 非空的断言。

function getArea(shape: Shape) {
    switch (shape.kind) {
        // shape: Circle
        case "circle":
            return Math.PI * shape.radius ** 2;
        // shape: Square
        case "square":
            return shape.sideLength ** 2;
    }
}

这里最重要的是 Shape 的编码。向 TypeScript 传达正确的信息是至关重要的,这个信息就是 CircleSquare 实际上是具有特定种类字段的两个独立类型。这样做让我们写出类型安全的TypeScript代码, 看起来与我们本来要写的JavaScript没有区别。从那里,类型系统能够做 "正确 "的事情,并找出我们 switch 语句的每个分支中的类型.

辨证的联合体不仅仅适用于谈论圆形和方形。它们适合于在JavaScript中表示任何类型的消息传递方案, 比如在网络上发送消息( client/server 通信),或者在状态管理框架中编码突变.

4.10 never类型与穷尽性检查

在缩小范围时,你可以将一个联合体的选项减少到你已经删除了所有的可能性并且什么都不剩的程度。 在这些情况下,TypeScript将使用一个 never 类型来代表一个不应该存在的状态。

never 类型可以分配给每个类型;但是,没有任何类型可以分配给never(除了never本身)。这意味着你可以使用缩小并依靠 never 的出现在 switch 语句中做详尽的检查。

例如,在我们的 getArea 函数中添加一个默认值,试图将形状分配给 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;
        	return _exhaustiveCheck;
    }
}

image-20220312220623399

五 函数更多

函数是任何应用程序的基本构件,无论它们是本地函数,从另一个模块导入,还是一个类上的方法。它 们也是值,就像其他值一样,TypeScript有很多方法来描述如何调用函数。让我们来学习一下如何编写描述函数的类型。

posted @ 2022-03-12 23:10  bleaka  阅读(664)  评论(0编辑  收藏  举报