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

TypeScript条件类型与映射类型高阶技巧

2024-05-106.9k 阅读

条件类型的基础概念

在 TypeScript 中,条件类型是一种基于条件进行类型选择的类型语法。它的基本语法形式为 T extends U ? X : Y,这看起来非常像 JavaScript 中的三元运算符 condition ? ifTrue : ifFalse,只不过这里操作的是类型。

假设我们有一个简单的场景,定义一个函数 identity,它接收一个参数并返回该参数,但是我们希望这个函数的返回类型能根据传入参数的类型来动态决定。如果传入的是 string 类型,返回类型就是 string;如果传入的是其他类型,返回类型就是 number。可以这样使用条件类型来实现:

type ReturnTypeIfString<T> = T extends string ? string : number;

function identity<T>(arg: T): ReturnTypeIfString<T> {
    return arg as ReturnTypeIfString<T>;
}

let result1 = identity('hello'); // result1 的类型是 string
let result2 = identity(123); // result2 的类型是 number

这里 ReturnTypeIfString 就是一个条件类型。它检查 T 是否是 string 类型,如果是则选择 string 作为返回类型,否则选择 number

条件类型的分发特性

条件类型有一个重要的特性叫做分发(Distribution)。当条件类型作用于联合类型时,它会自动地对联合类型中的每一个成员进行条件判断,并将结果合并为一个新的联合类型。

例如,定义一个条件类型 IsString,它判断传入的类型是否是 string

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

type StringOrNumber = string | number;
type Result = IsString<StringOrNumber>; 
// Result 的类型是 true | false

这里 StringOrNumber 是一个联合类型,IsString<StringOrNumber> 实际上会被处理为 IsString<string> | IsString<number>,即 true | false

提取类型

  1. Extract 类型
    • Extract<T, U> 用于从类型 T 中提取出可以赋值给类型 U 的类型。例如,从一个联合类型 string | number | boolean 中提取出 stringnumber 组成的联合类型,可以这样做:
type AllTypes = string | number | boolean;
type StringOrNumber = Extract<AllTypes, string | number>; 
// StringOrNumber 的类型是 string | number
  • 其内部实现实际上是利用了条件类型的分发特性:
type Extract<T, U> = T extends U ? T : never;
  • 这里对于联合类型 T,会对每个成员进行 T extends U 的判断,如果满足则保留该成员,不满足则用 never 代替,最后合并结果。
  1. Exclude 类型
    • Exclude<T, U>Extract 相反,它从类型 T 中排除可以赋值给类型 U 的类型。比如,从 string | number | boolean 中排除 stringnumber
type AllTypes = string | number | boolean;
type OnlyBoolean = Exclude<AllTypes, string | number>; 
// OnlyBoolean 的类型是 boolean
  • 其实现为:
type Exclude<T, U> = T extends U ? never : T;
  • 同样利用了条件类型的分发,对联合类型 T 的每个成员进行判断,满足 T extends U 的用 never 代替,不满足的保留,最后合并得到排除后的类型。

条件类型中的 infer 关键字

infer 关键字用于在条件类型中声明一个待推断的类型变量。它主要用在条件类型的 extends 子句中,让我们可以从类型关系中提取出特定的类型。

例如,我们有一个 Promise 类型,想要提取出 Promiseresolve 的值的类型。可以这样定义一个条件类型:

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

let promise1: Promise<string> = Promise.resolve('success');
type PromiseResolvedType = ResolveType<typeof promise1>; 
// PromiseResolvedType 的类型是 string

这里 infer U 声明了一个类型变量 U,表示 Promise 内部 resolve 的值的类型。T extends Promise<infer U> 条件判断 T 是否是 Promise 类型,如果是,则返回 U,否则返回 never

映射类型的基础概念

映射类型是一种通过对已有类型的属性进行变换,创建新类型的方式。它的基本语法是 { [P in K]: V },其中 P 是属性名,K 是属性名的联合类型,V 是属性值的类型。

例如,假设有一个简单的类型 User

type User = {
    name: string;
    age: number;
};

我们可以通过映射类型创建一个新的类型,将 User 类型的所有属性值变为 string 类型:

type StringifyUser = {
    [P in keyof User]: string;
};

这里 keyof User 得到 User 类型的所有属性名组成的联合类型 'name' | 'age'[P in keyof User]: string 表示对于 User 的每一个属性名 P,都创建一个新的属性,其类型为 string。所以 StringifyUser 的类型为 { name: string; age: string; }

映射类型的修饰符

  1. readonly 修饰符
    • 可以在映射类型中使用 readonly 修饰符来创建只读属性的新类型。例如,将 User 类型的所有属性变为只读:
type ReadonlyUser = {
    readonly [P in keyof User]: User[P];
};
  • 这里 readonly [P in keyof User]: User[P] 表示对于 User 的每一个属性 P,创建一个只读属性,其类型与 User 中对应属性的类型相同。
  1. ? 修饰符(可选属性)
    • 使用 ? 修饰符可以将属性变为可选的。例如,将 User 类型的所有属性变为可选:
type OptionalUser = {
    [P in keyof User]?: User[P];
};
  • 这样 OptionalUser 类型的对象中,nameage 属性都变为可选的了。

映射类型的高级技巧 - 移除属性修饰符

有时候我们需要移除属性的修饰符,比如将只读属性变为可写属性,或者将可选属性变为必选属性。这可以通过映射类型和条件类型结合来实现。

  1. 移除 readonly 修饰符
    • 定义一个类型 RemoveReadonly 来移除只读修饰符:
type RemoveReadonly<T> = {
    -readonly [P in keyof T]: T[P];
};

type ReadonlyUser = {
    readonly name: string;
    readonly age: number;
};

type WritableUser = RemoveReadonly<ReadonlyUser>; 
// WritableUser 的类型为 { name: string; age: number; },属性不再是只读的
  • 这里 -readonly 表示移除 readonly 修饰符。对于 ReadonlyUser 的每一个属性,通过 [P in keyof T]: T[P] 重新定义属性,并且移除了 readonly 修饰符。
  1. 移除可选属性修饰符
    • 定义一个类型 MakeRequired 来将可选属性变为必选属性:
type MakeRequired<T> = {
    [P in keyof T]-?: T[P];
};

type OptionalUser = {
    name?: string;
    age?: number;
};

type RequiredUser = MakeRequired<OptionalUser>; 
// RequiredUser 的类型为 { name: string; age: number; },属性变为必选
  • 这里 -? 表示移除 ? 修饰符,将可选属性变为必选属性。

条件类型与映射类型的结合使用

  1. 根据条件映射属性类型
    • 假设有一个类型 Todo,其中有些属性是 string 类型,有些是 number 类型。我们想要创建一个新类型,将 string 类型的属性变为 readonlynumber 类型的属性保持不变。
type Todo = {
    title: string;
    completed: boolean;
    priority: number;
};

type ConditionalMap = {
    [P in keyof Todo]: Todo[P] extends string ? readonly Todo[P] : Todo[P];
};

// ConditionalMap 的类型为 { readonly title: string; completed: boolean; priority: number; }
  • 这里通过 [P in keyof Todo] 遍历 Todo 的所有属性,然后利用条件类型 Todo[P] extends string ? readonly Todo[P] : Todo[P] 判断属性值的类型,如果是 string 则变为 readonly,否则保持不变。
  1. 条件类型与映射类型实现属性过滤
    • 假设我们有一个类型 AllProps,包含 nameageemail 属性,我们只想保留 string 类型的属性。
type AllProps = {
    name: string;
    age: number;
    email: string;
};

type FilteredProps = {
    [P in keyof AllProps as AllProps[P] extends string ? P : never]: AllProps[P];
};

// FilteredProps 的类型为 { name: string; email: string; }
  • 这里 as AllProps[P] extends string ? P : never 是一个类型映射的 as 子句。它会根据条件 AllProps[P] extends string 来过滤属性名。如果属性值是 string 类型,则保留该属性名 P,否则用 never 代替。最后根据保留的属性名重新构建类型。

利用条件类型和映射类型实现类型转换工具

  1. 将对象类型转换为数组类型
    • 我们可以创建一个工具类型,将对象类型的属性值转换为数组类型。例如,将 User 类型转换为 [string, number] 这样的数组类型。
type User = {
    name: string;
    age: number;
};

type ObjectToArray<T> = {
    [P in keyof T]: T[P];
}[keyof T][];

type UserArray = ObjectToArray<User>; 
// UserArray 的类型为 [string, number]
  • 首先 { [P in keyof T]: T[P]; } 构建了一个与 T 相同属性的类型,[keyof T] 取到所有属性值的联合类型,最后 [] 将联合类型转换为数组类型。
  1. 将函数参数类型转换为对象类型
    • 假设有一个函数类型,我们想将其参数类型转换为对象类型。例如,函数 (name: string, age: number) => void,我们要得到 { name: string; age: number; } 这样的对象类型。
type Func = (name: string, age: number) => void;

type ParametersToObject<T extends (...args: any[]) => any> = {
    [K in keyof Parameters<T> as string extends Parameters<T>[K]
      ? never
       : number extends Parameters<T>[K]
          ? never
           : `arg${K}`]: Parameters<T>[K];
};

type FuncParamsObject = ParametersToObject<Func>; 
// FuncParamsObject 的类型为 { arg0: string; arg1: number; }
  • 这里 Parameters<T> 得到函数 T 的参数类型组成的元组类型。然后通过映射类型和条件类型,为每个参数创建一个属性,属性名为 arg${K},属性值为参数的类型。并且通过条件类型排除了 stringnumber 这种泛型类型(防止错误匹配)。

条件类型与映射类型在实际项目中的应用场景

  1. API 响应数据处理
    • 在前端开发中,通过 API 获取数据后,响应数据的类型可能比较复杂。例如,API 可能返回一个包含不同类型数据的对象,其中有些属性是可选的。我们可以使用条件类型和映射类型来对响应数据进行类型处理,使其更符合业务需求。
    • 假设 API 响应数据类型为:
type ApiResponse = {
    data: {
        user: {
            name: string;
            age?: number;
            email: string;
        };
        posts: {
            title: string;
            content: string;
        }[];
    };
    status: number;
};
  • 我们可能只关心 user 部分的数据,并且想将 user 中的可选属性变为必选。可以这样处理:
type UserData = ApiResponse['data']['user'];
type RequiredUserData = MakeRequired<UserData>;

type ProcessedApiResponse = {
    user: RequiredUserData;
    status: ApiResponse['status'];
};
  • 这里先提取出 user 部分的数据类型 UserData,然后使用之前定义的 MakeRequired 映射类型将 UserData 中的可选属性变为必选,最后构建出符合需求的 ProcessedApiResponse 类型。
  1. 组件库开发
    • 在组件库开发中,不同的组件可能有不同的属性类型需求。例如,一个表单组件可能有一些通用的属性,如 disabledreadonly 等,同时不同的表单元素(如输入框、下拉框)又有各自特定的属性。
    • 假设我们有一个基础表单组件类型 BaseFormProps
type BaseFormProps = {
    disabled?: boolean;
    readonly?: boolean;
};
  • 对于输入框组件,除了基础属性,还有 type(如 'text''password' 等)和 placeholder 属性:
type InputProps = BaseFormProps & {
    type: 'text' | 'password';
    placeholder: string;
};
  • 对于下拉框组件,除了基础属性,还有 options 属性:
type Option = {
    label: string;
    value: string;
};
type SelectProps = BaseFormProps & {
    options: Option[];
};
  • 这里通过联合类型和映射类型的思想,复用了 BaseFormProps 的属性,同时为不同的表单组件添加了特定的属性。并且可以进一步利用条件类型来处理属性之间的依赖关系,比如如果 readonlytrue,则 disabled 应该自动设置为 true 等。
  1. 类型安全的状态管理
    • 在使用状态管理库(如 Redux)时,保证状态和操作的类型安全非常重要。例如,我们有一个状态类型 AppState
type AppState = {
    user: {
        name: string;
        age: number;
    };
    settings: {
        theme: 'light' | 'dark';
        fontSize: number;
    };
};
  • 我们可以使用映射类型和条件类型来创建对应的 action 类型。例如,对于更新 user 部分的 action,我们可以这样定义:
type UpdateUserAction = {
    type: 'UPDATE_USER';
    payload: {
        [P in keyof AppState['user']]?: AppState['user'][P];
    };
};
  • 这里 [P in keyof AppState['user']]?: AppState['user'][P] 表示 payload 中的属性是 AppState['user'] 的属性,并且是可选的,这样可以方便地更新 user 部分的部分属性。通过这种方式,可以利用条件类型和映射类型来构建类型安全的状态管理体系。

处理复杂类型场景下的条件类型与映射类型

  1. 嵌套类型的处理
    • 当面对嵌套类型时,条件类型和映射类型的应用会变得更加复杂。例如,有一个嵌套类型 NestedObject
type NestedObject = {
    a: {
        b: {
            c: string;
            d: number;
        };
        e: boolean;
    };
    f: {
        g: {
            h: string;
        };
    };
};
  • 假设我们要将所有叶子节点的 string 类型属性变为 readonly。可以通过递归的方式结合条件类型和映射类型来实现:
type MakeStringPropsReadonly<T> = {
    [P in keyof T]: T[P] extends object
      ? MakeStringPropsReadonly<T[P]>
       : T[P] extends string
          ? readonly T[P]
           : T[P];
};

type ProcessedNestedObject = MakeStringPropsReadonly<NestedObject>; 
// ProcessedNestedObject 的类型为 { a: { b: { readonly c: string; d: number; }; e: boolean; }; f: { g: { readonly h: string; }; }; }
  • 这里 MakeStringPropsReadonly 类型通过递归处理嵌套对象。对于对象的每个属性,如果属性值是对象类型,则继续调用 MakeStringPropsReadonly 处理;如果属性值是 string 类型,则变为 readonly;其他类型保持不变。
  1. 处理函数类型中的条件与映射
    • 函数类型也可以结合条件类型和映射类型进行复杂的处理。例如,我们有一个函数类型 FuncWithOptionalArgs,其中部分参数是可选的:
type FuncWithOptionalArgs = (a: string, b?: number, c: boolean) => void;
  • 我们想创建一个新的函数类型,将可选参数变为必选,并且为每个参数添加一个前缀 prefix_。可以这样实现:
type MakeArgsRequiredAndPrefix<T extends (...args: any[]) => any> = (
    ...args: {
        [K in keyof Parameters<T>]-?: `prefix_${Parameters<T>[K]}`;
    }
) => ReturnType<T>;

type ProcessedFunc = MakeArgsRequiredAndPrefix<FuncWithOptionalArgs>; 
// ProcessedFunc 的类型为 (a: 'prefix_string', b: 'prefix_number', c: 'prefix_boolean') => void
  • 这里 Parameters<T> 得到函数 T 的参数类型,通过映射类型 [K in keyof Parameters<T>]-?: prefix_${Parameters[K]} 将每个参数变为必选并添加前缀。ReturnType` 保持函数的返回类型不变。

条件类型与映射类型的性能考虑

  1. 类型运算的复杂度
    • 虽然条件类型和映射类型为我们提供了强大的类型操作能力,但它们也可能带来一定的性能开销。尤其是在复杂的嵌套和递归使用时,类型检查器需要进行大量的类型运算。
    • 例如,一个深度嵌套且递归的映射类型可能会导致类型检查时间显著增加。假设我们有一个非常复杂的递归映射类型 ComplexRecursiveType
type DeepNested = {
    value: number;
    next?: DeepNested;
};

type ComplexRecursiveType<T extends object> = {
    [P in keyof T]: T[P] extends object
      ? ComplexRecursiveType<T[P]>
       : T[P];
};
  • 如果使用这个类型去处理一个深度嵌套的对象,类型检查器需要对每一层嵌套进行类型运算,这会消耗大量的时间和资源。在实际项目中,应尽量避免过度复杂的类型运算,尤其是在性能敏感的场景下。
  1. 编译时间影响
    • 复杂的条件类型和映射类型会延长编译时间。因为 TypeScript 编译器在编译时需要解析和验证这些类型。例如,在一个大型项目中,如果有大量的类型定义使用了复杂的条件类型和映射类型相互嵌套,编译时间可能会从几秒延长到几十秒甚至更久。
    • 为了优化编译时间,可以将复杂的类型定义拆分成多个简单的类型定义,逐步进行类型转换和处理。同时,避免在不必要的地方使用过于复杂的类型运算,确保类型定义简洁明了,这样既能提高代码的可读性,也有助于减少编译时间。

通过深入理解和灵活运用 TypeScript 的条件类型与映射类型的高阶技巧,开发者可以编写出更加类型安全、可维护且高效的代码,无论是在小型项目还是大型企业级应用中,都能提升开发效率和代码质量。