TS进阶篇 | TS高级类型之字面量类型、联合类型、交叉类型
TypeScript中除了基本类型之外,还定义了很多高级类型,高级类型包括字面量类型、联合类型、交叉类型、索引类型、映射类型、条件类型、this类型等。因为内容太多,所以这篇文章先来介绍前三个类型,其余类型会在高级类型的下篇介绍。
系列文章:
一、字面量类型
在 TypeScript 中,字面量不仅可以表示值,还可以表示类型,即所谓的字面量类型。TypeScript 支持 3 种字面量类型:字符串字面量类型、数字字面量类型、布尔字面量类型。对应的字符串字面量、数字字面量、布尔字面量分别拥有与其值一样的字面量类型:
let str: 'hello world' = 'hello world';
let num: 996 = 996;
let bool: true = true
1. 字面量类型的使用
(1)字符串字面量
字符串字面量类型其实就是字符串常量,与字符串类型不同的是它是具体的值:
type Name = "TS";
const name1: Name = "test"; // error 不能将类型"test"分配给类型"TS"
const name2: Name = "TS";
实际上,定义单个的字面量类型并没有太大用处,它的应用场景是可以把多个字面量类型组合成一个联合类型,用来描述拥有明确成员的实用的集合:
type Direction = "north" | "east" | "south" | "west";
function getDirectionFirstLetter(direction: Direction) {
return direction.substr(0, 1);
}
getDirectionFirstLetter("test"); // error 类型"test"的参数不能赋给类型“Direction”的参数
getDirectionFirstLetter("east");
这里我们使用四个字符串字面量类型组合成了一个联合类型,这样编译器就会检查我们使用的参数是否是指定的字面量类型集合中的成员。通过这种方式,可以将函数的参数限定为更具体的类型。这不仅提升了代码的可读性,还保证了函数的参数类型。
(2)数字字面量
数字字面量类型和字符串字面量类型差不多,都是指定类型为具体的值:
type Age = 18;
interface Info {
name: string;
age: Age;
}
const info: Info = {
name: "TS",
age: 28 // error 不能将类型“28”分配给类型“18”
};
(3)布尔字面量
布尔字面量和上面的两个类似,不在多说:
let success: true
let fail: false
let value: true | false
由于布尔值只有true和false两种,所以以下两种类型意思一样的:
let value: true | false
let value: boolean
2. 字面量类型的拓宽
在ES6中提出了两个新的声明变量的关键字:let和const,那当他们定义的变量的值相同时,变量的类型是一样的吗?
先来看使用const定义变量的例子:
const str = "hello world";
const num = 996;
const bool = false;
这里const定义了三个不能变的常量,在不写类型注解的情况下,TypeScript 会推断出它的类型为赋值字面量的类型。这样就不能再改变变量的值。
再来看使用let定义变量的例子:
let str = "hello world";
let num = 996;
let bool = false;
这里没有写注解的变量的类型就变成了赋值字面量类型的父类型,比如str的类型是字符串字面量类型"hello world"的父类型string,num的类型是数字字面量类型996的父类型number,bool的类型是布尔字面量类型false的父类型boolean。这样就意味着,我们可以给这三个变量分别赋值string、number、boolean类型的值:
str = "hello TypeScript";
num = 666;
bool = true;
这种将字面量类型转换为其父类型的设计就是字面量类型的拓宽。 通过 let 或 var 定义的变量、函数形参、对象的非只读属性,如果指定了初始值且未显式添加类型注解,那么它们推断出来的类型就是指定的初始值字面量类型拓宽后的类型,这就是字面量类型拓宽。
下面通过一个例子来理解一下字面量类型拓宽:
let str = 'hello'; // 类型是 string
let strFun = (str = 'hello') => str; // 类型是 (str?: string) => string;
const specifiedStr = 'hello'; // 类型是 'this is string'
let str2 = specifiedStr; // 类型是 'string'
let strFun2 = (str = specifiedStr) => str; // 类型是 (str?: string) => string;
第一段代码中通过let定义了字符串str,是一个形参,并且没有显式的声明其类型,属于是类型拓宽,所以变量和形参推断出类型为string。
第二段代码中通过const定义了字符串specifiedStr,这个字符串是常量,不能进行修改,所以specifiedStr的类型为hello字面量类型,后面的str2遍历和strFun2函数形参被赋值了字面量类型的常量,并且没有显式的声明其类型,所以变量、形参的类型都被拓宽了,并没有被指定为它对应的字面量类型。这也是符合我们预期的。
二、联合类型
1. 联合类型的使用
如果希望属性为多种类型之一,如字符串或者数组,这时联合类型就派上用场了(它使用 | 作为标记,如 string | number)。**联合类型可以理解为多个类型的并集。**联合类型用来表示变量、参数的类型不是某个单一的类型,而可能是多种不同的类型的组合:
function formatCommandline(command: string[] | string) {
let line = '';
if (typeof command === 'string') {
line = command.trim();
} else {
line = command.join(' ').trim();
}
}
联合类型表示一个值可以是几种类型之一,用竖线 | 分隔每个类型,所以 number | string | boolean 表示一个值可以是number、string、boolean类型中的任意一种。
可以使用类型别名抽离联合类型:
type Command = string[] | string
2. 类型缩减
说完了联合类型的基本使用,那如果定义的联合类型的包含数字类型和数字字面量类型这种情况,会有什么效果呢?实际上,由于数字类型是数字字面量类型的父类型,所以最后会缩减为数字类型。同样string和boolean在这种情况下也会发生类型缩减。
看下面的例子:
type UnionNum = 1 | number; // 类型是number
type UniomStr = "string" | string; // 类型是string
type UnionBool = false | boolean; // 类型是boolean
在这种情况下,TypeScript会对类型进行缩减,将字面量类型去掉,保留原始类型。
但是这样也会造成一个问题:编译器只能提示我们定义的变量是那个原始的类型:
不过,TypeScript提供了一种方式来控制类型缩减,只需给父类型添加"& {}"即可:
此时,其他字面量类型就不会被缩减,在编辑器中字符串字面量str1、str2等就可以自动提示出来了。
除此之外,当联合类型的成员是接口类型,并满足其中一个接口的属性是另一个接口属性的子集,这个属性也会进行类型缩减:
type UnionInterface = {
age: "18"
} | {
age: "18" | "25",
[key: string]: string;
}
由于 "18"
是 "18" | "25"
的子集,所以age属性的类型会变成 "18" | "25"。
3. 可辨识联合类型
可以把单例类型、联合类型、类型保护和类型别名这几种类型进行合并,来创建一个叫做可辨识联合类型,它也可称作标签联合或代数数据类型。
所谓单例类型,可以理解为符合单例模式的数据类型,比如枚举成员类型,字面量类型。
可辨识联合类型要求具有两个要素:
- 具有普通的单例类型属性。
- 一个类型别名,包含了那些类型的联合。
可辨识联合类型就是为了保证每个case都能被处理。
来看一个例子:
interface Square {
kind: "square"; // 具有辨识性的属性
size: number;
}
interface Rectangle {
kind: "rectangle"; // 具有辨识性的属性
height: number;
width: number;
}
interface Circle {
kind: "circle"; // 具有辨识性的属性
radius: number;
}
type Shape = Square | Rectangle | Circle;
function getArea(s: Shape) {
switch (s.kind) {
case "square":
return s.size * s.size;
case "rectangle":
return s.height * s.width;
case "circle":
return Math.PI * s.radius ** 2;
}
}
上面这个例子中,我们的 Shape 即可辨识联合类型,它是三个接口的联合,而这三个接口都有一个 kind 属性,且每个接口的 kind 属性值都不相同,能够起到标识作用。 函数内应该包含联合类型中每一个接口的 case。
如果函数内没有包含联合类型中每一个接口的 case。希望编译器应该给出提示。有以下两种完整性检查的方法:使用 strictNullChecks和使用 never 类型。
(1)使用 strictNullChecks
对上面的例子加一种接口:
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
height: number;
width: number;
}
interface Circle {
kind: "circle";
radius: number;
}
interface Triangle {
kind: "triangle";
bottom: number;
height: number;
}
type Shape = Square | Rectangle | Circle | Triangle;
function getArea(s: Shape) {
switch (s.kind) {
case "square":
return s.size * s.size;
case "rectangle":
return s.height * s.width;
case "circle":
return Math.PI * s.radius ** 2;
}
}
这里,Shape 联合有四种接口,但函数的 switch 里只包含三个 case,这个时候编译器并没有提示任何错误,因为当传入函数的是类型是 Triangle 时,没有任何一个 case 符合,则不会有 return 语句执行,那么函数是默认返回 undefined。所以可以利用这个特点,结合 strictNullChecks编译选项,可以开启 strictNullChecks,然后让函数的返回值类型为 number,那么当返回 undefined 的时候,就会报错:
function getArea(s: Shape): number {
// error Function lacks ending return statement and return type does not include 'undefined'
switch (s.kind) {
case "square":
return s.size * s.size;
case "rectangle":
return s.height * s.width;
case "circle":
return Math.PI * s.radius ** 2;
}
}
这种方法简单,但是对旧代码支持不好,因为strictNullChecks这个配置项是2.0版本才加入的,如果使用的是低于这个版本的,这个方法并不会有效。
(2)使用 never 类型
当函数返回一个错误或者不可能有返回值的时候,返回值类型为 never。所以可以给 switch 添加一个 default 流程,当前面的 case 都不符合的时候,会执行 default 后的逻辑:
function assertNever(value: never): never {
throw new Error("Unexpected object: " + value);
}
function getArea(s: Shape) {
switch (s.kind) {
case "square":
return s.size * s.size;
case "rectangle":
return s.height * s.width;
case "circle":
return Math.PI * s.radius ** 2;
default:
return assertNever(s); // error 类型“Triangle”的参数不能赋给类型“never”的参数
}
}
采用这种方式,需要定义一个额外的 asserNever 函数,但是这种方式不仅能够在编译阶段提示遗漏了判断条件,而且在运行时也会报错。
三、交叉类型
1. 交叉类型的使用
交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到成为一种类型,合并后的类型将拥有所有成员类型的特性。交叉类型可以理解为多个类型的交集。
可以使用“&”操作符来声明交叉类型:
type Overlapping = string & number;
如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何意义的,因为不会有变量同时满足这些类型,那这个类型实际上就等于never类型。
2. 交叉类型的使用场景
上面说了一般情况下使用交叉类型是没有意义的,那什么时候该使用交叉类型呢?下面就来看看交叉类型的使用场景。
(1)合并接口类型
将多个接口类型合并成为一个类型是交叉类型的一个常见的使用场景。这样就能相当于实现了接口的继承,也就是所谓的合并接口类型:
type Person = {
name: string;
age: number;
} & {
height: number;
weight: number;
} & {
id: number;
}
const person: Person = {
name: "zhangsan",
age: 18,
height: 180,
weight: 60,
id: 123456
}
这里我们通过交叉类型使Person同时拥有了三个接口类型中的5个属性。
那如果两个接口中的同一个属性定义了不同的类型会发生了什么情况呢?
type Person = {
name: string;
age: number;
} & {
age: string;
height: number;
weight: number;
}
两个接口中都拥有age属性,并且类型分别是number和string,那么在合并后,age的类型就是string & number,就是一个 never 类型:
type Person = {
name: string;
age: number;
} & {
age: string;
height: number;
weight: number;
}
const person: Person = {
name: "zhangsan",
age: 18, // Type 'number' is not assignable to type 'never'.ts(2322)
height: 180,
weight: 60,
}
如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 age 属性的类型就是两者中的子类型:
type Person = {
name: string;
age: number;
} & {
age: 18;
height: number;
weight: number;
}
const person: Person = {
name: "zhangsan",
age: 20, // Type '20' is not assignable to type '18'.ts(2322)
height: 180,
weight: 60,
}
这里第二个接口中的age是一个数字字面量类型,它是number类型的子类型,所以合并之后的类型为字面量类型18。
(2)合并联合类型
交叉类型另外一个常见的使用场景就是合并联合类型。可以合并多个联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员:
type A = "blue" | "red" | 996;
type B = 996 | 666;
type C = A & B;
const c: C = 996;
如果多个联合类型中没有相同的类型成员,那么交叉出来的类型就是never类型:
type A = "blue" | "red";
type B = 996 | 666;
type C = A & B;
const c: C = 996; // Type 'number' is not assignable to type 'never'.ts(2322)
转载自:https://juejin.cn/post/7003142725058969636