TypeScript联合、交叉类型解读
# 联合类型
联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。主要通过“|”操作符分隔类型的语法来表示联合类型。这里,我们可以把“|”类比为 JavaScript 中的逻辑或 “||”,只不过前者表示可能的类型。
假设我们有以下函数, 我们知道它仅有number或是string类型,但当我们对入参没有明确定义的时候,我们仍然可以为其传递任意值,这显然不符合我们的预期,因为 size 应该是更明确的,即可能也只可能是 number 或 string 这两种可选类型的类型。
function formatPX(size: unknown) {
if (typeof size === 'number') {
return `${size}px`;
}
if (typeof size === 'string') {
return `${parseInt(size) || 0}px`;
}
throw Error(` 仅支持 number 或者 string`);
}
formatPX(13);
formatPX('13px');
2
3
4
5
6
7
8
9
10
11
所幸当我们采用联合类型时,可以解决这个问题:
function formatPX(size: number | string) {
}
formatPX(13); // ok
formatPX('13px'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型
2
3
4
5
6
当然,我们可以组合任意个、任意类型来构造更满足我们诉求的类型。比如,我们希望给前边的示例再加一个 unit 参数表示可能单位,这个时候就可以声明一个字符串字面类型组成的联合类型:
function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
// ...
}
formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)
2
3
4
5
6
我们定义了unit入参,它的类型是由多个字符串字面量类型组合而成的集合 我们也可以把接口类型联合起来表示更复杂的结构:
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
return {
// ...
} as Bird | Fish;
};
const Pet = getPet();
Pet.layEggs(); // ok
Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
从上边的示例可以看到,在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。但是,如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫来区分不同的成员类型。
# 交叉类型
在 TypeScript 中,还存在一种类似逻辑与行为的类型——交叉类型(Intersection Type),它可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性, 使用“&”操作符来声明。如下代码所示
{
type Useless = string & number;
}
2
3
4
很显然,如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型,比如既是 string 类型又是 number 类型。因此,在上述的代码中,类型别名 Useless 的类型就是个 never。
# 合并接口类型
联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型,如下代码所示:
type IntersectionType = { id: number; name: string; }
& { age: number };
const mixed: IntersectionType = {
id: 1,
name: 'name',
age: 18
}
2
3
4
5
6
7
我们通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集。
如果取并集的时候,出现了两个同名的属性,且类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:
type IntersectionTypeConfict = { id: number; name: string; }
& { age: number; name: number; };
const mixedConflict: IntersectionTypeConfict = {
id: 1,
name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
age: 2
};
2
3
4
5
6
7
TIPS:这时候很多人就疑惑,为什么是never呢?never 表示永远不会发生值的类型,是所有类型的子类型,当不相干的两个属性合并的时候,never就是string和number共同的一个子类型,所以交集时会将属性定义成never
此时,我们赋予 mixedConflict 任意类型的 name 属性值都会提示类型错误。而如果我们不设置 name 属性,又会提示一个缺少必选的 name 属性的错误。在这种情况下,就意味着上述代码中交叉出来的 IntersectionTypeConfict 类型是一个无用类型。 如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型。 如下所示示例中 name 属性的类型就是数字字面量类型 2,因此,我们不能把任何非 2 之外的值赋予 name 属性。
type IntersectionTypeConfict = { id: number; name: 2; }
& { age: number; name: number; };
let mixedConflict: IntersectionTypeConfict = {
id: 1,
name: 2, // ok
age: 2
};
mixedConflict = {
id: 1,
name: 22, // '22' 类型不能赋给 '2' 类型
age: 2
};
2
3
4
5
6
7
8
9
10
11
12
# 合并联合类型
我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。这里,我们也可以将合并联合类型理解为求交集。
在如下示例中,两个联合类型交叉出来的类型 IntersectionUnion 其实等价于 'em' | 'rem',所以我们只能把 'em' 或者 'rem' 字符串赋值给 IntersectionUnion 类型的变量。
type UnionA = 'px' | 'em' | 'rem' | '%';
type UnionB = 'vh' | 'em' | 'rem' | 'pt';
type IntersectionUnion = UnionA & UnionB;
const intersectionA: IntersectionUnion = 'em'; // ok
const intersectionB: IntersectionUnion = 'rem'; // ok
const intersectionC: IntersectionUnion = 'px'; // ts(2322)
const intersectionD: IntersectionUnion = 'pt'; // ts(2322)
2
3
4
5
6
7
既然是求交集,如果多个联合类型中没有相同的类型成员,交叉出来的类型自然就是 never
type UnionC = 'em' | 'rem';
type UnionD = 'px' | 'pt';
type IntersectionUnionE = UnionC & UnionD;
const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型
2
3
4
在上述示例中,因为 UnionC 和 UnionD 没有交集,交叉出来的类型 IntersectionUnionE 就是 never,所以我们不能把任何类型的值赋予 IntersectionUnionE 类型的变量。
# 联合、交叉组合
联合、交叉类型本身就可以直接组合使用, 两者之间有着优先级问题。实际上,联合、交叉运算符不仅在行为上表现一致,还在运算的优先级和 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致。联合操作符 | 的优先级低于交叉操作符 &,同样,我们可以通过使用小括弧 () 来调整操作符的优先级。
type UnionIntersectionA = { id: number; } & { name: string; } | { id: string; } & { name: number; }; // 交叉操作符优先级高于联合操作符
type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级
2
3
4
# 类型缩减
如果我们将相同类型下的原始类型和字面量类型组合成联合类型会发生什么?效果就是类型缩减成原始类型了。
type URStr = 'string' | string; // 类型是 string
type URNum = 2 | number; // 类型是 number
type URBoolen = true | boolean; // 类型是 boolean
enum EnumUR {
ONE,
TWO
}
type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR
2
3
4
5
6
7
8
TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。
可是这个缩减,却极大地削弱了 IDE 自动提示的能力,如下代码所示:
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string
在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。 不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}”即可。
type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留
在看一个例子
type UnionInterce =
| {
age: number;
}
| ({
age: never;
[key: string]: string;
});
const O: UnionInterce = {
age: 2,
string: 'string'
};
2
3
4
5
6
7
8
9
10
11
12
在上述代码中,我们在第 3 行定义了 number 类型的 age 属性,第 6 行定义了 never 类型的 age 属性,等价于 age 属性的类型是由 number 和 never 类型组成的联合类型,由于never是所有类型的子类型,这个时候类型缩减成了number类型,所以我们可以把 number 类型的值(比如说数字字面量 1)赋予 age 属性;但是不能把其他任何类型的值(比如说字符串字面量 'string' )赋予 age。