MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript 高级类型:联合类型与交叉类型的区别

2021-02-204.1k 阅读

一、联合类型(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 表示可能为空的值

有时候,一个变量可能存在两种状态:有值和无值(通常用 nullundefined 表示)。例如,我们从服务器获取数据,可能成功获取到数据(某个具体类型),也可能因为网络问题等没有获取到数据(nullundefined):

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 交叉类型接口,因此它必须实现 drawselect 方法。

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 举例说明本质区别

假设我们有两个类型 AnimalFlyable

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 类型中,变量可以是 AB 的交叉类型,也可以是 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 表示电子邮件地址或手机号码,通过类型谓词函数 isEmailisPhone 来判断具体的类型并进行相应的验证。

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 类型是 RegularUserAdminUser 的联合类型,RegularUser 继承自 BaseUserAdminUser 通过交叉类型在 BaseUser 的基础上添加了 isAdmin 属性。通过 in 操作符可以判断用户类型并进行相应的处理。

六、联合类型与交叉类型的注意事项

6.1 联合类型的类型判断复杂性

在使用联合类型时,由于变量可能是多种类型之一,需要通过类型判断(如 typeofinstanceof 或自定义类型谓词)来确定实际类型,以便访问特定类型的属性和方法。这可能会导致代码中出现较多的条件判断语句,增加代码的复杂性。例如:

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 代码更加健壮、可读和易于维护。