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

TypeScript 条件类型深入解析

2024-06-077.4k 阅读

条件类型基础

在 TypeScript 中,条件类型是一种强大的类型编程工具,它允许我们根据类型的条件来选择不同的类型。条件类型的基本语法与 JavaScript 中的三元运算符类似,采用 T extends U ? X : Y 的形式。其中,TU 是类型操作数,extends 关键字用于检测 T 是否可以赋值给 U。如果可以,即 TU 的子类型,那么条件类型的结果为 X;否则,结果为 Y

例如,下面定义了一个简单的条件类型 IfString

type IfString<T> = T extends string ? 'is string' : 'not string';

type StringResult = IfString<string>; // 'is string'
type NumberResult = IfString<number>; // 'not string'

在上述代码中,IfString 类型接受一个类型参数 T。当 Tstring 类型时,IfString<string> 的结果为 'is string';当 Tnumber 类型时,IfString<number> 的结果为 'not string'

分布式条件类型

当条件类型作用于联合类型时,会发生分布式行为。具体来说,当条件类型的类型参数是一个联合类型时,TypeScript 会自动将该联合类型拆分为单个类型,并对每个类型分别应用条件类型,最后再将结果合并为一个联合类型。

看下面的示例:

type IsString<T> = T extends string ? true : false;

type StringOrNumber = string | number;
type StringOrNumberResult = IsString<StringOrNumber>; 
// true | false

在这个例子中,StringOrNumberstringnumber 的联合类型。当 IsString<StringOrNumber> 执行时,它会被拆分为 IsString<string>IsString<number>,分别得到 truefalse,最后合并为 true | false

需要注意的是,分布式条件类型只在条件类型的类型参数是裸类型参数(即没有被其他类型包裹)时才会发生。例如:

type Box<T> = { value: T };
type IsBoxString<T> = Box<T> extends Box<string> ? true : false;

type BoxedStringOrNumber = Box<string> | Box<number>;
type BoxedResult = IsBoxString<BoxedStringOrNumber>; 
// false

这里 BoxedStringOrNumber 虽然是联合类型,但 IsBoxString 中的 Box<T> 不是裸类型参数,所以不会发生分布式行为。BoxedStringOrNumber 整体被视为一个类型来判断,由于 Box<string> | Box<number> 不能赋值给 Box<string>,所以结果为 false

条件类型中的 infer 关键字

infer 关键字在条件类型中用于在 extends 子句中声明一个类型变量,这个类型变量会根据类型匹配自动推导出来。这在处理泛型类型提取子类型信息时非常有用。

例如,考虑一个简单的 Promise 类型,我们想要提取 Promise 中包裹的值的类型:

type PromiseValue<T> = T extends Promise<infer U> ? U : never;

type StringPromise = Promise<string>;
type StringPromiseValue = PromiseValue<StringPromise>; 
// string

type NumberPromise = Promise<number>;
type NumberPromiseValue = PromiseValue<NumberPromise>; 
// number

PromiseValue 类型中,T extends Promise<infer U> 表示如果 TPromise 类型,那么推断出 Promise 中包裹的值的类型并将其命名为 U。如果 T 不是 Promise 类型,则返回 never

再看一个稍微复杂一点的例子,从函数返回值类型中提取类型:

type ReturnType<T> = T extends (...args: any[]) => infer U ? U : never;

function add(a: number, b: number): number {
    return a + b;
}

type AddReturnType = ReturnType<typeof add>; 
// number

这里 ReturnType 类型通过 T extends (...args: any[]) => infer U 来匹配函数类型,并推断出函数的返回值类型 U

条件类型的递归使用

条件类型可以递归使用,这使得我们能够实现复杂的类型转换和计算。递归条件类型通常用于处理嵌套的类型结构。

例如,我们想要实现一个类型来深度扁平化一个嵌套的数组类型:

type DeepFlatten<T> = T extends Array<infer U> ? (U extends Array<any> ? DeepFlatten<U> : U)[] : T;

type NestedArray = [1, [2, [3]], 4];
type FlattenedArray = DeepFlatten<NestedArray>; 
// [1, 2, 3, 4]

DeepFlatten 类型中,首先判断 T 是否是数组类型。如果是,再判断数组元素 U 是否也是数组类型。如果是,则递归调用 DeepFlatten 处理 U;如果不是,则直接保留 U 作为数组元素。通过这种递归方式,实现了对嵌套数组的深度扁平化。

另一个例子是实现一个类型来获取对象所有属性值的联合类型,包括嵌套对象中的属性值:

type ValueOf<T> = T extends object ? {
    [K in keyof T]: ValueOf<T[K]>;
}[keyof T] : T;

interface User {
    name: string;
    age: number;
    address: {
        city: string;
        country: string;
    };
}

type UserValue = ValueOf<User>; 
// string | number

ValueOf 类型中,首先判断 T 是否是对象类型。如果是,通过映射类型对 T 的每个属性进行递归调用 ValueOf,最后通过索引类型 [keyof T] 取出所有值的联合类型。如果 T 不是对象类型,则直接返回 T

条件类型与类型保护

条件类型在类型保护方面也有重要应用。类型保护是一种运行时检查机制,它可以缩小类型的范围。条件类型可以帮助我们创建类型保护函数的类型定义。

例如,我们定义一个类型保护函数来判断一个值是否是 string 类型:

function isString(value: any): value is string {
    return typeof value ==='string';
}

type StringOrNumber = string | number;
let value: StringOrNumber = 10;
if (isString(value)) {
    // 这里 value 的类型被缩小为 string
    console.log(value.length); 
} else {
    // 这里 value 的类型为 number
    console.log(value.toFixed(2)); 
}

在这个例子中,isString 函数返回一个 value is string 类型的断言。结合条件类型,我们可以在 if 语句块中根据这个断言来缩小 value 的类型范围。

条件类型与映射类型结合

条件类型常常与映射类型一起使用,以实现更复杂的类型转换。映射类型允许我们对对象类型的每个属性进行转换,而条件类型可以在转换过程中添加条件判断。

例如,我们想要创建一个新的类型,将对象中所有字符串类型的属性转换为大写:

type UppercaseStringProperties<T> = {
    [K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K];
};

interface Person {
    name: string;
    age: number;
    address: string;
}

type TransformedPerson = UppercaseStringProperties<Person>; 
// { name: string; age: number; address: string; }
// 这里 name 和 address 实际上在运行时会是大写字符串

UppercaseStringProperties 类型中,通过映射类型 [K in keyof T] 遍历 T 的所有属性。对于每个属性,使用条件类型判断属性值是否为 string 类型。如果是,则使用 Uppercase 类型将其转换为大写字符串;如果不是,则保持原类型不变。

条件类型在工具类型中的应用

TypeScript 提供了许多内置的工具类型,这些工具类型大多基于条件类型实现。例如,Exclude 工具类型用于从一个联合类型中排除另一个联合类型中的类型:

type Exclude<T, U> = T extends U ? never : T;

type AllColors ='red' | 'green' | 'blue' | 'yellow';
type PrimaryColors ='red' | 'green' | 'blue';
type SecondaryColors = Exclude<AllColors, PrimaryColors>; 
// 'yellow'

Exclude 类型中,对于 T 中的每个类型,如果它是 U 中的类型,则返回 never,否则返回该类型,从而实现了从 T 中排除 U 中的类型。

另一个例子是 Extract 工具类型,它用于从一个联合类型中提取出另一个联合类型中的类型:

type Extract<T, U> = T extends U ? T : never;

type AllNumbers = 1 | 2 | 3 | 4 | 5;
type EvenNumbers = 2 | 4;
type ExtractedEvenNumbers = Extract<AllNumbers, EvenNumbers>; 
// 2 | 4

Extract 类型与 Exclude 类型相反,它通过条件类型判断,保留 T 中属于 U 的类型。

条件类型的局限性

虽然条件类型非常强大,但它也存在一些局限性。例如,在处理复杂的类型关系时,类型推断可能会变得非常复杂,导致难以理解和维护。此外,由于条件类型是在编译时进行计算的,过度使用递归条件类型可能会导致编译时间过长。

另外,条件类型在处理一些动态类型场景时存在一定困难。例如,在处理 any 类型时,条件类型的行为可能与预期不符。因为 any 类型可以赋值给任何类型,所以 any extends U 总是为 true,这可能会影响条件类型的正常判断。

type AnyCheck<T> = T extends string ? 'is string' : 'not string';

type AnyResult = AnyCheck<any>; 
// 'is string'

这里 AnyCheck<any> 的结果为 'is string',因为 any 可以赋值给 string,这与我们可能预期的根据实际运行时类型来判断不符。

条件类型与 JavaScript 运行时行为的差异

需要明确的是,条件类型是 TypeScript 在编译时进行类型计算的机制,与 JavaScript 的运行时行为是不同的。例如,在 JavaScript 中,我们可以通过 instanceof 操作符在运行时判断一个对象是否是某个类的实例:

class Animal {}
class Dog extends Animal {}

let pet = new Dog();
console.log(pet instanceof Dog); 
// true

而在 TypeScript 中,条件类型虽然可以进行类似的类型判断,但这是在编译时完成的:

type IsDog<T> = T extends Dog? true : false;

class Animal {}
class Dog extends Animal {}

let pet: Animal = new Dog();
type PetIsDog = IsDog<typeof pet>; 
// true

这里 IsDog<typeof pet> 是在编译时确定的类型,而 pet instanceof Dog 是在运行时进行的判断。这种差异在实际编程中需要特别注意,避免混淆编译时类型检查和运行时类型检查。

优化条件类型的使用

为了更好地使用条件类型,我们可以采取一些优化措施。首先,尽量避免过度复杂的递归条件类型。如果确实需要处理复杂的嵌套类型结构,可以考虑将其分解为多个简单的条件类型和映射类型,逐步实现类型转换。

其次,在使用条件类型与联合类型时,要清楚分布式条件类型的行为,确保类型推断符合预期。可以通过添加注释或中间类型定义来提高代码的可读性。

另外,在定义条件类型时,要考虑到类型参数的各种可能取值,特别是在与 anynever 等特殊类型结合使用时,要仔细测试和验证条件类型的行为。

例如,在处理可能包含 nullundefined 的联合类型时:

type WithoutNullable<T> = T extends null | undefined? never : T;

type StringOrNull = string | null;
type NonNullableString = WithoutNullable<StringOrNull>; 
// string

通过这种方式,我们可以明确地从联合类型中排除 nullundefined 类型,使代码更加健壮。

条件类型在大型项目中的实践

在大型 TypeScript 项目中,条件类型可以用于实现统一的类型转换和类型检查逻辑。例如,在一个前后端分离的项目中,后端返回的数据可能有多种格式,前端需要对这些数据进行类型处理。

假设后端返回的数据可能是一个对象数组,每个对象可能包含不同的属性,我们可以使用条件类型来处理这种复杂的情况:

interface BaseResponse {
    id: number;
}

interface UserResponse extends BaseResponse {
    name: string;
    age: number;
}

interface ProductResponse extends BaseResponse {
    name: string;
    price: number;
}

type ApiResponse = UserResponse | ProductResponse;

type ExtractName<T> = T extends { name: string }? T['name'] : never;

type AllNames = ExtractName<ApiResponse>; 
// string

在这个例子中,ExtractName 条件类型可以从 ApiResponse 联合类型中提取出所有包含 name 属性的类型中的 name 属性值的联合类型。这样可以方便地对后端返回的数据进行统一的类型处理,提高代码的可维护性和复用性。

条件类型与其他类型系统特性的结合

条件类型可以与 TypeScript 的其他类型系统特性,如接口、类型别名、交叉类型等结合使用,进一步增强类型表达能力。

例如,我们可以通过条件类型和交叉类型来实现一种“可选属性合并”的功能:

type OptionalMerge<T, U> = {
    [K in keyof (T & U)]: K extends keyof T? (K extends keyof U? T[K] | U[K] : T[K]) : U[K];
};

interface A {
    name: string;
    age: number;
}

interface B {
    age: number;
    address: string;
}

type Merged = OptionalMerge<A, B>; 
// { name: string; age: number; address: string; }

OptionalMerge 类型中,通过交叉类型 T & U 获取 TU 共有的属性。然后使用条件类型判断每个属性在 TU 中的存在情况,实现属性值类型的合并。这种结合方式可以灵活地处理不同接口之间的属性合并问题。

条件类型的未来发展

随着 TypeScript 的不断发展,条件类型可能会得到进一步的增强和改进。例如,未来可能会有更强大的类型推断能力,使得复杂条件类型的编写更加简洁和直观。

同时,社区也在不断探索如何更好地利用条件类型来解决实际开发中的问题,可能会出现更多基于条件类型的优秀工具类型和最佳实践。这将有助于开发者更高效地使用 TypeScript 进行大型项目的开发,提升代码的质量和可维护性。

总之,条件类型是 TypeScript 类型系统中非常重要且强大的一部分,深入理解和掌握它对于编写高质量的 TypeScript 代码至关重要。通过不断实践和探索,我们可以充分发挥条件类型的潜力,解决各种复杂的类型编程问题。