TypeScript 高级类型:联合类型与交叉类型的区别
一、联合类型(Union Types)
1.1 联合类型的定义
在 TypeScript 中,联合类型允许一个变量具有多种类型中的一种。它使用竖线(|
)来分隔不同的类型。例如,我们可以定义一个变量,它既可以是 string
类型,也可以是 number
类型:
let value: string | number;
value = 'hello';
value = 42;
在上述代码中,value
变量被定义为 string | number
联合类型,这意味着它可以被赋值为字符串或者数字。
1.2 联合类型的使用场景
1.2.1 函数参数的灵活性
当函数需要接受不同类型的参数时,联合类型就显得非常有用。比如,我们有一个函数 printValue
,它既可以打印字符串,也可以打印数字:
function printValue(value: string | number) {
if (typeof value ==='string') {
console.log(value.length);
} else {
console.log(value.toFixed(2));
}
}
printValue('hello');
printValue(42);
在这个函数中,通过 typeof
操作符来判断传入参数的实际类型,从而执行不同的逻辑。如果不使用联合类型,我们可能需要定义两个不同的函数来处理字符串和数字。
1.2.2 表示可能为空的值
有时候,一个变量可能存在两种状态:有值和无值(通常用 null
或 undefined
表示)。例如,我们从服务器获取数据,可能成功获取到数据(某个具体类型),也可能因为网络问题等没有获取到数据(null
或 undefined
):
let userData: { name: string } | null;
// 模拟从服务器获取数据
const response = getResponseFromServer();
if (response.success) {
userData = { name: response.data.name };
} else {
userData = null;
}
在上述代码中,userData
被定义为 { name: string } | null
联合类型,它表示要么是包含 name
属性的对象,要么是 null
。
1.3 联合类型的类型推断
TypeScript 会根据变量的赋值情况进行类型推断。当我们对联合类型的变量进行操作时,TypeScript 会尝试推断出当前实际的类型。例如:
let value: string | number;
value = 'hello';
let length = value.length; // 这里 TypeScript 能够推断出 value 此时是 string 类型,所以可以访问 length 属性
value = 42;
// let length2 = value.length; // 这里会报错,因为 value 现在是 number 类型,没有 length 属性
但是,当联合类型中有多个类型时,如果没有明确的类型判断,TypeScript 只能访问这些类型共有的属性和方法。比如:
function printLength(value: string | number) {
// console.log(value.length); // 报错,number 类型没有 length 属性
// 这里只能访问 string 和 number 共有的属性和方法,比如 toString()
console.log(value.toString());
}
1.4 联合类型的解构
在解构联合类型的数组或对象时,需要注意类型的兼容性。例如,对于一个可能是字符串数组或数字数组的联合类型:
let array: string[] | number[];
array = ['a', 'b'];
let [first] = array; // first 类型为 string
array = [1, 2];
let [second] = array; // second 类型为 number
对于对象的解构,如果对象属性的类型是联合类型,也需要特别处理:
let obj: { value: string | number };
obj = { value: 'hello' };
let { value: strValue } = obj; // strValue 类型为 string
obj = { value: 42 };
let { value: numValue } = obj; // numValue 类型为 number
二、交叉类型(Intersection Types)
2.1 交叉类型的定义
交叉类型使用 &
符号将多个类型合并为一个类型。这个新类型具有所有被合并类型的特性。例如,我们可以定义一个新类型,它既是 { name: string }
类型,又是 { age: number }
类型:
type Person = { name: string } & { age: number };
let person: Person = { name: 'John', age: 30 };
在上述代码中,Person
类型要求对象同时具备 name
属性(字符串类型)和 age
属性(数字类型)。
2.2 交叉类型的使用场景
2.2.1 扩展现有类型
当我们需要在现有类型的基础上添加新的属性或方法时,可以使用交叉类型。例如,假设我们有一个 User
类型,现在我们希望为其添加一些管理员权限相关的属性:
type User = { name: string };
type Admin = { isAdmin: boolean };
type AdminUser = User & Admin;
let adminUser: AdminUser = { name: 'Admin John', isAdmin: true };
通过交叉类型,AdminUser
类型既具备 User
类型的 name
属性,又具备 Admin
类型的 isAdmin
属性。
2.2.2 混合多种功能接口
在面向对象编程中,有时候一个对象可能需要实现多个接口的功能。比如,我们有一个 Drawable
接口表示可绘制的对象,一个 Selectable
接口表示可选择的对象,现在我们需要一个既可以绘制又可以选择的对象:
interface Drawable {
draw(): void;
}
interface Selectable {
select(): void;
}
class Graphic implements Drawable & Selectable {
draw() {
console.log('Drawing...');
}
select() {
console.log('Selecting...');
}
}
let graphic = new Graphic();
graphic.draw();
graphic.select();
在这里,Graphic
类实现了 Drawable & Selectable
交叉类型接口,因此它必须实现 draw
和 select
方法。
2.3 交叉类型的类型推断
与联合类型不同,交叉类型的类型推断相对简单。当我们定义一个交叉类型的变量并赋值时,TypeScript 会确保赋值的对象具备所有交叉类型的属性和方法。例如:
type A = { a: string };
type B = { b: number };
type AB = A & B;
let ab: AB = { a: 'hello', b: 42 }; // 正确,对象具备 A 和 B 类型的所有属性
// let ab2: AB = { a: 'hello' }; // 报错,缺少 B 类型的 b 属性
// let ab3: AB = { b: 42 }; // 报错,缺少 A 类型的 a 属性
2.4 交叉类型的解构
在解构交叉类型的对象时,同样需要确保对象具备所有交叉类型的属性。例如:
type C = { c: string };
type D = { d: number };
type CD = C & D;
let cd: CD = { c: 'value', d: 10 };
let { c, d } = cd; // 正确,c 为 string 类型,d 为 number 类型
三、联合类型与交叉类型的本质区别
3.1 类型组合方式
联合类型是 “或” 的关系,即一个变量可以是多种类型中的任意一种。它更像是一种选择,在不同的情况下,变量可以表现为不同的类型。例如 string | number
,变量要么是字符串,要么是数字。
而交叉类型是 “与” 的关系,一个类型必须同时具备所有被交叉类型的特性。如 { name: string } & { age: number }
,表示这个类型的对象既要包含 name
属性(字符串类型),又要包含 age
属性(数字类型)。
3.2 类型推断与访问属性
在联合类型中,由于变量可能是多种类型之一,TypeScript 只能在明确判断出实际类型后,才能访问特定类型的属性和方法。在没有明确类型判断时,只能访问多种类型共有的属性和方法。
而交叉类型要求对象必须具备所有交叉类型的属性和方法,TypeScript 在推断类型时,会确保赋值的对象满足所有交叉类型的要求。只要对象具备所有交叉类型的属性和方法,就可以进行正常的访问。
3.3 使用场景差异
联合类型常用于表示变量可能具有多种不同类型的情况,比如函数参数的灵活性,或者表示可能为空的值。它提供了一种灵活的方式来处理不同类型的数据。
交叉类型主要用于扩展现有类型,或者将多个功能接口合并到一个类型中。它强调的是多种类型特性的融合,使得一个对象能够同时具备多种不同类型的功能。
3.4 举例说明本质区别
假设我们有两个类型 Animal
和 Flyable
:
type Animal = { name: string };
type Flyable = { fly: () => void };
如果我们使用联合类型 Animal | Flyable
,那么一个变量可以是 Animal
类型(只有 name
属性),也可以是 Flyable
类型(只有 fly
方法):
let entity: Animal | Flyable;
entity = { name: 'Dog' };
// entity.fly(); // 报错,此时 entity 是 Animal 类型,没有 fly 方法
entity = { fly: () => console.log('Flying') };
// console.log(entity.name); // 报错,此时 entity 是 Flyable 类型,没有 name 属性
而如果使用交叉类型 Animal & Flyable
,则要求一个对象必须同时具备 Animal
类型的 name
属性和 Flyable
类型的 fly
方法:
let creature: Animal & Flyable = { name: 'Bird', fly: () => console.log('Bird is flying') };
console.log(creature.name);
creature.fly();
四、联合类型与交叉类型的复杂应用
4.1 联合类型在泛型中的应用
在泛型函数中,联合类型可以增加函数的通用性。例如,我们定义一个函数 identity
,它可以接受任何类型的参数并返回该参数,但是我们希望它可以接受联合类型的参数,并返回对应的联合类型:
function identity<T>(arg: T): T {
return arg;
}
let result: string | number = identity<string | number>('hello' as string | number);
result = identity(42 as string | number);
在上述代码中,通过泛型 T
,函数 identity
可以接受 string | number
联合类型的参数,并返回相同的联合类型。
4.2 交叉类型在接口继承中的应用
当一个接口需要继承多个其他接口时,可以使用交叉类型来实现。例如:
interface Shape {
area(): number;
}
interface Colorable {
color: string;
}
interface ColoredShape extends Shape & Colorable {}
class Rectangle implements ColoredShape {
color: string;
constructor(public width: number, public height: number, color: string) {
this.color = color;
}
area() {
return this.width * this.height;
}
}
let rectangle = new Rectangle(5, 10, 'blue');
console.log(rectangle.area());
console.log(rectangle.color);
在这个例子中,ColoredShape
接口通过交叉类型继承了 Shape
接口的 area
方法和 Colorable
接口的 color
属性,Rectangle
类实现了 ColoredShape
接口,因此需要实现 area
方法并具备 color
属性。
4.3 联合类型与交叉类型的嵌套
有时候,我们可能会遇到联合类型与交叉类型嵌套的情况。例如:
type A = { a: string };
type B = { b: number };
type C = { c: boolean };
// 联合类型中包含交叉类型
type UnionWithIntersection = (A & B) | C;
let value1: UnionWithIntersection = { c: true };
let value2: UnionWithIntersection = { a: 'hello', b: 42 };
// 交叉类型中包含联合类型
type IntersectionWithUnion = A & (B | C);
let value3: IntersectionWithUnion = { a: 'world', b: 10 };
let value4: IntersectionWithUnion = { a: 'world', c: false };
在 UnionWithIntersection
类型中,变量可以是 A
和 B
的交叉类型,也可以是 C
类型。而在 IntersectionWithUnion
类型中,变量必须具备 A
类型的属性,并且同时是 B
或者 C
类型。
五、联合类型与交叉类型在实际项目中的应用案例
5.1 前端表单验证
在前端开发中,表单验证是一个常见的需求。假设我们有一个表单,其中的某个字段既可以是有效的电子邮件地址(字符串格式),也可以是手机号码(字符串格式,且符合特定的格式规则)。我们可以使用联合类型来定义这个字段的类型:
type Email = string & { __email__: never };
type Phone = string & { __phone__: never };
function isEmail(str: string): str is Email {
return /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(str);
}
function isPhone(str: string): str is Phone {
return /^1[3-9]\d{9}$/.test(str);
}
type ContactInfo = Email | Phone;
function validateContactInfo(info: ContactInfo) {
if (isEmail(info)) {
console.log('Valid email:', info);
} else if (isPhone(info)) {
console.log('Valid phone:', info);
} else {
console.log('Invalid contact info');
}
}
validateContactInfo('test@example.com' as ContactInfo);
validateContactInfo('13800138000' as ContactInfo);
validateContactInfo('invalid' as ContactInfo);
在上述代码中,通过联合类型 ContactInfo
表示电子邮件地址或手机号码,通过类型谓词函数 isEmail
和 isPhone
来判断具体的类型并进行相应的验证。
5.2 组件属性扩展
在 React 或 Vue 等前端框架中,我们经常需要对组件的属性进行扩展。例如,我们有一个基础的 Button
组件,它有一些基本属性,现在我们希望为某些特殊的按钮添加一些额外的属性,如 isLoading
表示按钮是否正在加载。我们可以使用交叉类型来实现:
// React 示例
interface ButtonProps {
label: string;
onClick: () => void;
}
interface LoadingButtonProps {
isLoading: boolean;
}
type LoadingButton = ButtonProps & LoadingButtonProps;
const LoadingButtonComponent: React.FC<LoadingButton> = ({ label, onClick, isLoading }) => {
return (
<button onClick={onClick} disabled={isLoading}>
{isLoading? 'Loading...' : label}
</button>
);
};
// 使用
<LoadingButtonComponent label="Click me" onClick={() => console.log('Clicked')} isLoading={false} />
在这个例子中,LoadingButton
类型通过交叉类型扩展了 ButtonProps
,使得按钮组件可以具备加载状态的属性和逻辑。
5.3 数据模型的灵活处理
在处理从服务器获取的数据时,有时候数据的结构可能会根据不同的情况有所变化。例如,我们获取用户信息,对于普通用户和管理员用户,数据结构可能会有一些差异。我们可以使用联合类型和交叉类型来灵活处理这种情况:
type BaseUser = { id: number; name: string };
type RegularUser = BaseUser;
type AdminUser = BaseUser & { isAdmin: boolean };
type User = RegularUser | AdminUser;
function displayUser(user: User) {
if ('isAdmin' in user) {
console.log(`${user.name} is an admin`);
} else {
console.log(`${user.name} is a regular user`);
}
}
let regularUser: RegularUser = { id: 1, name: 'John' };
let adminUser: AdminUser = { id: 2, name: 'Admin Jane', isAdmin: true };
displayUser(regularUser);
displayUser(adminUser);
在上述代码中,User
类型是 RegularUser
和 AdminUser
的联合类型,RegularUser
继承自 BaseUser
,AdminUser
通过交叉类型在 BaseUser
的基础上添加了 isAdmin
属性。通过 in
操作符可以判断用户类型并进行相应的处理。
六、联合类型与交叉类型的注意事项
6.1 联合类型的类型判断复杂性
在使用联合类型时,由于变量可能是多种类型之一,需要通过类型判断(如 typeof
、instanceof
或自定义类型谓词)来确定实际类型,以便访问特定类型的属性和方法。这可能会导致代码中出现较多的条件判断语句,增加代码的复杂性。例如:
function processValue(value: string | number) {
if (typeof value ==='string') {
console.log(value.length);
} else {
console.log(value.toFixed(2));
}
}
过多的条件判断可能会使代码的可读性和维护性下降,因此在设计联合类型时,需要权衡其带来的灵活性和代码复杂性。
6.2 交叉类型的属性冲突
当使用交叉类型合并多个类型时,如果这些类型中有同名属性且类型不一致,会导致类型错误。例如:
type X = { prop: string };
type Y = { prop: number };
// type XY = X & Y; // 报错,prop 属性类型冲突
在这种情况下,需要重新审视类型设计,可能需要调整属性名或者使用更复杂的类型处理方式来解决冲突。
6.3 联合类型与交叉类型的性能影响
虽然 TypeScript 是在编译阶段进行类型检查,不会直接影响运行时性能,但复杂的联合类型和交叉类型可能会增加编译时间。尤其是在大型项目中,大量使用嵌套的联合类型和交叉类型,可能会导致编译速度变慢。因此,在实际应用中,需要根据项目规模和性能要求,合理使用这两种类型,避免过度复杂的类型定义。
6.4 文档和代码可读性
联合类型和交叉类型虽然强大,但可能会使代码的可读性变差,特别是对于不熟悉 TypeScript 高级类型的开发者。因此,在使用这些类型时,要提供清晰的文档说明,解释每个联合类型或交叉类型的含义和使用场景。同时,在命名类型时,要尽量使用有意义的名称,以便其他开发者能够快速理解代码的意图。例如,对于前面提到的 ContactInfo
类型,命名就很直观地表示了它是用于表示联系信息的联合类型。
在实际项目中,要根据具体情况,谨慎使用联合类型和交叉类型,充分发挥它们的优势,同时避免引入不必要的复杂性和问题。通过合理的类型设计和良好的代码规范,可以使 TypeScript 代码更加健壮、可读和易于维护。