假如我们有一个叫padLeft的函数:
function padLeft(padding: number | string, input: string): string {
throw new Error("Not implemented yet!");
}
如果padding是number类型,表示我们要输入的空格的数量。如果padding是string类型,就应该是为input直接设置padding。现在让我们尝试去完善paddigLeft传递number类型的padding时的逻辑。
function padLeft(padding: number | string, input: string) {
return " ".repeat(padding) + input;
// Argument of type 'string | number' is not assignable to parameter of type 'number'.
// Type 'string' is not assignable to type 'number'.
}
哦吼,在padding的位置发生了一个错误。TypeScript警告我们将number | string赋值给number或许不能给我们想要的效果。换个角度来说,我们没有明确检查padding是一个数字类型,我们也没有对它是一个string类型时的情况进行处理,所以让我们完善这两点:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
这段JavaScript代码看起来非常无趣,但是这就是重点。除了我们的类型注释之外,这段TypeScript就像是JavaScript。TypeScript的类型系统的目标是让书写JavaScript的代码尽可能的简单。
虽然看起来不多,但是展开了讲其实有很多东西。最多的是TypeScript怎么使用静态类型去解析运行时的值,它涵盖了JavaScript的运行时流程控制的类型解析,比如if/else,三元表达式,循环,真值检查(truthiness check),等等这些都可以影响类型解析。
在if检查内部,TypeScript对于typeof padding === "number"这种特别的代码称为类型守卫(type guard)。TypeScript以我们程序的分支结构为依据,来分析特定的位置应该将值解析为什么类型。
TypeScript会查看这些特殊的检查(被称为type guard的检查),并且更进一步的来进行类型的推断,这种方式被称为类型收缩(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
}
TypeScript 可以理解几种不同的结构来缩小类型的范围。
typeof 类型守卫
正如我们所看见的,JavaScript支持typeof操作符,它可以在运行时提供关于值的非常基础的信息。TypeScrip通过typeof操作符来返回一个固定的string集合
- “string”
- “number”
- “bigint”
- “boolean”
- “symbol”
- “undefined”
- “object”
- “function”
现在让我们回到padLeft中,typeof运算符经常出现在许多 JavaScript 库中, 并且TypeScript可以明白它在不同的分支里是用来收缩类型的。
在TypeScript中,检查typeof返回的值是一种类型保护。因为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是一个object类型,同时也会检查它是否是一个数组类型。但是事实证明在JavaScript中,typeof null实际上也返回了"object",这是段不幸的历史遗留。
拥有足够经验的开发者或许不会感到奇怪,但也不是每个人都会在JavaScript中遇到这个。还好的是strs被类型缩窄为string[] | null,代替了string[]。这可能是我们下面说的真值(truthiness)的一个很好的开始。
真值收缩(Truthiness narrowing)
真值或许你在字典中并找不到的单词,但是你在JavaScript会经常听到它。在JavaScript中,我们可以在条件中使用表达式----&&,||,if语句,布尔值的反向操作符 !,当然还有更多。来看一个例子,if语法没有强制他们的条件必须是boolean类型。
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
在JavaScript中,类似if的结构首先会强转它的条件变为boolean类型而来解析他们,然后靠判断是true或false来选择分支。这些值:
- 0
- NaN
- ""(空字符串)
- 0n(bigint版本的0)
- null
- undefined
都会强制转换为false,并且不在上面的值都会被强制转换为true,你可能一直用Boolean函数强制转换他们到boolean类型,但是也可以通过!!去操作(后者的优势是会被TypeScript推断为true类型)。
// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true
用这种的地方相当的多,尤其是来防止null或者undefined这种值。来看个例子,我们尝试在printAll函数中使用真值收缩(Truthiness narrowing):
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) {
// !!!!!!!!!!!!!!!!
// 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);
}
}
}
我们在方法体外再包装了一层真值检查,但是这有个小缺陷:之后会不再处理空字符串。
TypeScript 根本不会影响到我们,但是如果您对JavaScript不太熟悉,这是要关注的地方。TypeScript 通常可以帮助你及早发现错误,但如果你选择对值不做任何事情,
它可以做的只有这么多,而不是影响到代码。如果你想要处理这种情况,你可以使用 linter 。
最后,关于类型收缩的最后一个要点是!操作符过滤了否定分支
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}
相等类型收缩(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这两个是相等的,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) {
//(parameter) strs: string[]
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
//(parameter) strs: string
}
}
}
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操作符的类型收缩(The In operator narrowing)
JavaScript有一个通过属性名来检查object是否有某个属性的操作符:in。记住这个,TypeScript将in视为缩小潜在类型的一种方式。
举个例子,在"value" in x中,value是一个字面量类型,而x是一个联合类型。"true"的分支收缩了x的类型,x的类型变为有可选的属性value或者必选的属性value。"false"的分支收缩x的类型为一个有可选的value属性或者没有value属性的类型。
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
为了重现可选属性会出现在两个分支中,来举个例子,两个人都会游和飞,因此使用in时会在两个分支中出现Human。
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 narrowing)
JavaScript有一个用来检查是否一个值是否是另一个值的实体。说到更仔细点,在JavaScript中使用x instanceof Foo来检查是否x的原项链(prototype chain)包含Foo.prototype。你可能已经猜到了,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。这是主要是x的类型声明的原因 ---- 这个x开始是string | number类型,同时TypeScript始终根据声明的类型检查可分配的类型值。
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如何在特定分支中缩小类型f的例子,但是,除了从变量在 if、while、条件语句中实现类型保护之外,还有其它重要方式可以做到。例如:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
padLeft从它的第一个if块中返回。TypeScript能够分析此代码并发现在padding是number类型的情况下,正文的其余部分(return padding + input)是无法访问到的。最终得到的是,在函数的剩余部分,TypeScript能够从padding(从string | number收缩成string类型)的类型中删除number类型.
这种基于可达性的代码分析称之为流程控制解析(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
}
使用类型断言(Using type predicates)
到目前为止,我们已经使用现有的JavaScript结构来缩小范围,但是有时你想要更直接地控制类型在整个代码中的变化方式。
这就需要要定义一个自定义的类型保护(user-defined type guard),其实我们只需要简单的定义一个返回类型是类型断言的函数:
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();
} else {
pet.fly();
}
请注意,TypeScript不仅知道在if分支中pet是Fish类型;它也知道在else分支中你不可能是Fish类型,你肯定是Bird类型。
你可能会使用类型守卫isFinsh来过滤一个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[];
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
拆分联合类型(Discriminated unions)
到目前位置,我们看到的大多数实例都集中在使用简单类型如string,boolean,number来缩小单个变量的范围。虽然这很常见,但大多数时候在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") {
// This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
// ...
}
}
我们可以写一个getArea函数来调用正确的逻辑来处理圆形或者正方形。我们来对圆形进行第一次处理:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// Object is possibly 'undefined'.
}
在开启strictNullChecks情况下,我们得到了一个错误----这是合理的,因为半径可能未定义。但是如果我们对kind属性进行适当的检查可以防止错误发生吗?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// Object is possibly 'undefined'.
}
}
嗯~ o( ̄▽ ̄)o,TypeScript仍然不知道应该去做什么。我们已经达到了比类型检查器更了解我们的值需要什么类型的地步。我们可以尝试使用非空断言(在shap.radius后面放!号)来告诉radius是肯定存在的。
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
不过这样看起来并不完美。我们不得不用非空断言来说服类型检查,表明shap.radius已定义,但如果之后我们开始修改代码,这些断言很容易出错。如果关闭strictNullChecks,那我们可以随意的访问这些字段中的任何一个(因为可选属性在假定读取它们的时候始终存在)。但是我们绝对可以做得更好。
现在的问题是Shape类型的编码使得类型检查器无法通过任何方式去基于Kind属性来知道是否有radius或者sideLength。我们需要用我们已经学到的东西和类型检查器来进行交流。处于这个目的,让我们重新来定义Shape类型。
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
现在,我们重新划分Shape类型成为两个Kind属性不同的类型。但是radius和slideLength被定义成了必须的在它们各自的类型当中。
让我们看看现在访问Shape的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
是可选的,我们拿到了一个错误(在strictNullChecks开启的情况下),因为TypeScript无法判断改属性是否存在。到现在为止,Shape是一个联合类型,TypeScript告诉我们shape
或许是Square类型,并且Square中没有radius的定义。两种解释都是正确的,但无论strictNullChecks是如何配置的,Shape的并集类型解析都会导致错误。
但是如果我们尝试再一次检查kind属性呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
//(parameter) shape: Circle
}
}
这样就摆脱了错误!当联合类型中的每个类型都包含具有文字类型的公共属性时,TypeScript认为这是一个能拆分的联合类型(discriminated unio),并且可以缩小联合类型到其成员类型。
这样做之后,Kind称为了共有的属性,从而将形状缩小为圆形。
相同的检查工作使用switch语句表现的也非常好。现在我们去不通过!断言来完善getArea。
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字段进行了划分--这些是至关重要的。做这些事情让我们写出了类型安全的TypeScript
代码,并且让它看起来和JavaScript的编码没有区别。这样之后,类型系统能够做“正确”的事情,并找出我们switch语句的每个分支中的类型。
拆分联合类型不仅仅对于圆形和正方形来说是非常有用的。它们也非常适合在JavaScript中表示任何类型的消息传递方案,比如通过网络发送消息(客户端/服务器通信),或者可以在状态管理框架中编写灵活的代码。
never类型(The never type)
缩小范围时,你可以将联合的成员缩小到消除所有的可能性为止。最后TypeScript将使用never类型来表示不应该存在的状态。
穷举检查(Exhaustiveness checking)
never类型能赋予任何类型,然而,没有任何类型可以赋值给never类型(除了never本身)。并且可以使用switch语句去做穷举检查,来收缩到never类型。
举个例子,添加default到我们的getArea函数中,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;
//Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}
参考 Narrowing