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

TypeScript中的类型推导与类型体操

2022-06-091.6k 阅读

类型推导基础

在TypeScript中,类型推导是一项强大的功能,它允许编译器在许多情况下自动推断出变量的类型。这极大地减少了显式类型声明的工作量,同时又能保证代码的类型安全性。

变量声明时的类型推导

当我们声明一个变量并同时初始化它时,TypeScript会根据初始化的值来推导变量的类型。例如:

let num = 10;
// 这里num的类型被推导为number

在这个例子中,由于我们将 10 这个数字赋值给 num,TypeScript 编译器能够自动推断出 num 的类型为 number。同样的道理也适用于其他基本类型:

let str = "hello";
// str的类型被推导为string
let bool = true;
// bool的类型被推导为boolean

函数返回值的类型推导

函数的返回值类型也能被TypeScript自动推导。考虑以下简单的函数:

function add(a: number, b: number) {
    return a + b;
}
// add函数的返回值类型被推导为number

在这个 add 函数中,由于我们返回了 a + b,而 ab 都是 number 类型,TypeScript 能够推断出该函数的返回值类型也是 number。即使我们没有显式地声明返回值类型,TypeScript 也能保证类型安全。如果我们不小心返回了一个非 number 类型的值,例如:

function add(a: number, b: number) {
    return "not a number";
    // 这里会报错,因为返回值类型与推导的number类型不匹配
}

编译器会立即报错,提示返回值类型错误。

上下文类型推导

上下文类型推导是TypeScript类型推导的另一个重要方面。它允许TypeScript根据表达式所在的上下文来推断类型。例如,在函数调用中,当我们传递一个匿名函数作为参数时:

function handleClick(callback: () => void) {
    callback();
}

handleClick(() => {
    console.log("Clicked!");
});
// 这里匿名函数的类型被上下文推导为() => void

handleClick 函数调用中,TypeScript 根据 handleClick 函数参数的类型要求 () => void,推断出传递的匿名函数的类型。即使我们没有显式地声明匿名函数的参数和返回值类型,TypeScript 也能正确地进行类型检查。

条件类型与类型推导

条件类型是TypeScript中用于根据条件进行类型选择的一种强大机制,它与类型推导紧密结合,为我们提供了更灵活的类型处理能力。

条件类型的基本语法

条件类型使用 T extends U ? X : Y 的语法,其中 TU 是类型,XY 也是类型。如果 T 能够赋值给 U,则结果类型为 X,否则为 Y。例如:

type IsString<T> = T extends string ? true : false;
type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false

在这个例子中,IsString 类型别名定义了一个条件类型。当我们使用 IsString<string> 时,因为 string 类型可以赋值给 string,所以结果为 true;而使用 IsString<number> 时,由于 number 类型不能赋值给 string,结果为 false

条件类型在类型推导中的应用

条件类型在类型推导中可以实现更复杂的类型转换。例如,我们可以定义一个类型,根据输入类型是否为数组,返回不同的结果:

type Flatten<T> = T extends Array<infer U> ? U : T;
type ArrayType = Flatten<string[]>; // string
type NonArrayType = Flatten<number>; // number

Flatten 类型别名中,T extends Array<infer U> 部分尝试推断 T 是否为数组类型,并提取数组元素的类型 U。如果 T 是数组类型,结果为数组元素的类型;否则,结果就是 T 本身。

分布式条件类型

分布式条件类型是条件类型在处理联合类型时的一种特殊行为,它为类型推导带来了更强大的能力。

分布式条件类型的行为

当条件类型的输入是一个联合类型时,TypeScript会将条件类型分别应用到联合类型的每个成员上,然后将结果联合起来。例如:

type ToString<T> = T extends any ? `${T}` : never;
type StringUnion = ToString<string | number>;
// StringUnion为"string" | "number"

在这个例子中,ToString 类型别名定义了一个条件类型,它将输入类型转换为字符串类型。当输入为 string | number 联合类型时,TypeScript 会分别对 stringnumber 应用条件类型,即 string extends any ? ${string} : nevernumber extends any ? ${number} : never,然后将结果联合起来,得到 "string" | "number"

分布式条件类型的应用场景

分布式条件类型在许多场景下都非常有用,比如类型过滤。假设我们有一个联合类型,只想保留其中的数字类型:

type FilterNumber<T> = T extends number ? T : never;
type NumberOnly = FilterNumber<string | number | boolean>;
// NumberOnly为number

这里 FilterNumber 类型别名利用分布式条件类型,将联合类型中的非数字类型过滤掉,只保留数字类型。

映射类型与类型推导

映射类型是TypeScript中用于创建新类型的一种方式,它通过对现有类型的属性进行映射和转换来生成新类型,与类型推导相互配合,提供了高度的灵活性。

映射类型的基本语法

映射类型使用 { [P in K]: T } 的语法,其中 K 是一个联合类型,通常是另一个类型的属性键,P 是从 K 中遍历出来的每个属性键,T 是新类型属性的值类型。例如:

type ReadonlyProps<T> = {
    readonly [P in keyof T]: T[P];
};
interface User {
    name: string;
    age: number;
}
type ReadonlyUser = ReadonlyProps<User>;
// ReadonlyUser的属性都变为只读

在这个例子中,ReadonlyProps 类型别名将 T 类型的所有属性都转换为只读属性。通过 keyof T 获取 T 的所有属性键,然后使用 readonly [P in keyof T]: T[P] 来创建新类型,其中每个属性都是只读的。

映射类型与类型推导的结合

映射类型可以与类型推导一起使用,实现更复杂的类型转换。比如,我们可以将一个对象类型的所有属性值转换为字符串类型:

type StringifyProps<T> = {
    [P in keyof T]: `${T[P]}`;
};
interface Data {
    value1: number;
    value2: boolean;
}
type StringifiedData = StringifyProps<Data>;
// StringifiedData的属性值都为字符串类型

StringifyProps 类型别名中,我们使用 [P in keyof T]: ${T[P]}来将T类型的每个属性值转换为字符串类型。这里结合了类型推导,根据T` 的属性值类型来生成新的字符串类型属性。

类型体操基础

类型体操是指在TypeScript类型系统中通过巧妙地组合各种类型操作,如条件类型、映射类型、递归等,来解决复杂的类型计算和转换问题,就像在类型层面进行“体操表演”一样。

类型体操的常见操作

  1. 类型递归:在类型定义中引用自身,实现循环式的类型计算。例如,我们可以定义一个类型来获取数组的深度:
type ArrayDepth<T, Depth extends number[] = []> =
    T extends Array<infer U> ? ArrayDepth<U, [...Depth, 0]> : Depth["length"];
type OneDArray = number[];
type TwoDArray = number[][];
type OneDDepth = ArrayDepth<OneDArray>; // 1
type TwoDDepth = ArrayDepth<TwoDArray>; // 2

ArrayDepth 类型别名中,通过递归地检查数组类型,每次递归时增加 Depth 数组的长度,最终得到数组的深度。

  1. 类型联合与交叉:联合类型(|)表示取值可以是多种类型之一,交叉类型(&)表示取值必须同时满足多种类型。例如:
type A = { a: string };
type B = { b: number };
type UnionAB = A | B;
type IntersectionAB = A & B;

UnionAB 类型的值可以是只具有 a 属性的对象,也可以是只具有 b 属性的对象;而 IntersectionAB 类型的值必须同时具有 ab 属性。

用类型体操实现类型转换

实现元组到对象的转换

假设我们有一个元组类型,希望将其转换为对象类型,其中元组的元素作为对象的属性值,属性名可以通过某种规则生成。例如:

type TupleToObject<T extends readonly any[]> = {
    [P in keyof T as `prop${P & number}`]: T[P];
};
type MyTuple = readonly ["value1", 2];
type MyObject = TupleToObject<MyTuple>;
// MyObject为{ prop0: "value1", prop1: 2 }

TupleToObject 类型别名中,我们使用 keyof T 获取元组的索引,然后通过 as prop${P & number}`` 将索引转换为属性名,最后将元组元素作为属性值,实现了元组到对象的转换。

实现对象属性的重映射

有时我们需要对对象的属性进行重映射,比如将属性名加上前缀。可以通过以下类型体操实现:

type RenameWithPrefix<T, Prefix extends string> = {
    [K in keyof T as `${Prefix}${K & string}`]: T[K];
};
interface UserInfo {
    name: string;
    age: number;
}
type PrefixedUserInfo = RenameWithPrefix<UserInfo, "user_">;
// PrefixedUserInfo为{ user_name: string, user_age: number }

RenameWithPrefix 类型别名中,我们遍历 T 的属性键 K,通过 as ${Prefix}${K & string}`` 为属性键加上前缀,从而实现属性的重映射。

类型体操中的递归与条件

递归条件类型实现类型扁平化

在处理复杂类型时,有时需要将嵌套的类型进行扁平化。例如,将嵌套的数组类型展开为一维数组类型。可以通过递归条件类型来实现:

type FlattenArray<T> = T extends Array<infer U> ? U extends Array<any> ? FlattenArray<U> : U : T;
type NestedArray = [1, [2, [3]]];
type FlattenedArray = FlattenArray<NestedArray>;
// FlattenedArray为1 | 2 | 3

FlattenArray 类型别名中,首先检查 T 是否为数组类型,如果是,再检查数组元素 U 是否也是数组类型。如果 U 是数组类型,则递归调用 FlattenArray;否则,直接返回数组元素 U。通过这种递归方式,实现了数组类型的扁平化。

递归映射类型实现深度只读

我们可以通过递归映射类型将一个对象及其嵌套对象的所有属性都设置为只读。例如:

type DeepReadonly<T> = {
    readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
interface NestedObject {
    prop1: string;
    prop2: {
        subProp1: number;
        subProp2: boolean;
    };
}
type DeepReadonlyObject = DeepReadonly<NestedObject>;
// DeepReadonlyObject的所有属性包括嵌套属性都为只读

DeepReadonly 类型别名中,通过 readonly [P in keyof T]T 的属性设置为只读。如果属性值 T[P] 是对象类型,则递归调用 DeepReadonly,从而实现深度只读。

类型体操中的实用技巧

使用 infer 进行类型提取

infer 关键字在类型体操中非常有用,它用于在条件类型中提取类型。例如,在处理函数类型时,我们可以提取函数的参数类型和返回值类型:

type GetParams<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;
type GetReturn<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
function addNumbers(a: number, b: number): number {
    return a + b;
}
type AddParams = GetParams<typeof addNumbers>; // [number, number]
type AddReturn = GetReturn<typeof addNumbers>; // number

GetParamsGetReturn 类型别名中,通过 infer 分别提取了函数的参数类型和返回值类型。

利用 keyof 和索引类型访问实现类型安全的属性访问

keyof 用于获取对象类型的所有属性键,结合索引类型访问,可以实现类型安全的属性访问。例如:

interface Person {
    name: string;
    age: number;
}
type NameType = Person["name"]; // string
type AgeType = Person["age"]; // number
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
let person: Person = { name: "John", age: 30 };
let name = getProperty(person, "name"); // name的类型为string

getProperty 函数中,通过 K extends keyof T 确保 key 参数是 obj 对象的合法属性键,然后返回对应的属性值,保证了类型安全。

类型体操在实际项目中的应用

在状态管理库中的应用

在状态管理库(如Redux)中,类型体操可以用于精确地定义action和reducer的类型。例如,我们有一个简单的计数器应用:

// 定义action类型
type IncrementAction = { type: "increment" };
type DecrementAction = { type: "decrement" };
type CounterAction = IncrementAction | DecrementAction;

// 定义reducer类型
type CounterReducer = (state: number, action: CounterAction) => number;

// 实现reducer
const counterReducer: CounterReducer = (state = 0, action) => {
    switch (action.type) {
        case "increment":
            return state + 1;
        case "decrement":
            return state - 1;
        default:
            return state;
    }
};

这里通过类型体操定义了精确的action和reducer类型,确保在状态管理过程中的类型安全。

在数据请求库中的应用

在数据请求库(如Axios)中,类型体操可以用于根据请求的响应数据类型来自动推导返回值类型。例如:

import axios from 'axios';

// 定义请求响应类型
interface UserResponse {
    id: number;
    name: string;
}

// 定义请求函数类型
type UserRequest = () => Promise<UserResponse>;

// 实现请求函数
const getUser: UserRequest = async () => {
    const response = await axios.get('/api/user');
    return response.data;
};

通过类型体操,我们明确了请求函数的返回值类型,使得在使用数据请求库时能够获得更好的类型支持。

类型体操的挑战与应对

类型复杂性导致的可读性问题

随着类型体操的复杂性增加,类型定义可能变得非常难以理解和维护。例如,深度嵌套的条件类型和递归映射类型可能会让代码看起来像一团乱麻。为了应对这个问题,我们可以采取以下措施:

  1. 使用类型别名和接口进行抽象:将复杂的类型定义封装成类型别名或接口,给它们起一个有意义的名字,提高代码的可读性。例如:
// 复杂的类型定义
type ComplexType = {
    [P in keyof SomeOtherType as `prefix_${P & string}`]: SomeOtherType[P] extends Array<infer U> ? U extends object ? DeepTransform<U> : U : never;
};

// 抽象为类型别名
type PrefixAndTransform<T> = {
    [P in keyof T as `prefix_${P & string}`]: TransformValue<T[P]>;
};

type TransformValue<V> = V extends Array<infer U> ? U extends object ? DeepTransform<U> : U : never;
  1. 添加注释:在复杂的类型定义旁边添加注释,解释类型的作用和实现逻辑。例如:
// 将数组元素类型转换为字符串类型,并为属性名添加前缀
type StringifyAndPrefixArray<T extends readonly any[]> = {
    // 获取元组的索引
    [P in keyof T as `prefix_${P & number}`]: `${T[P]}`;
};

类型推导失败的情况

在某些复杂的类型体操中,TypeScript的类型推导可能会失败,导致编译错误。这通常是因为类型系统无法处理过于复杂的逻辑,或者类型之间的关系不明确。例如,在递归类型定义中,如果没有正确设置递归终止条件,可能会导致类型推导无限循环。为了解决这个问题:

  1. 检查递归条件:确保递归类型定义有明确的终止条件。例如,在前面的 ArrayDepth 类型别名中,当 T 不再是数组类型时,递归终止。
  2. 简化类型定义:尝试简化复杂的类型定义,将其拆分成多个简单的类型定义,逐步实现所需的类型转换。例如,将一个复杂的条件类型拆分成多个条件类型,分别处理不同的情况。

高级类型体操案例分析

实现类型级别的四则运算

我们可以在类型层面实现简单的四则运算,例如加法。通过类型递归和条件类型来模拟数字的加法:

type Zero = [];
type Add<X extends number[], Y extends number[]> =
    Y extends [] ? X : Add<[...X, 0], Y extends [0, ...infer Rest] ? Rest : []>;
type One = Add<Zero, [0]>;
type Two = Add<One, [0]>;
type Three = Add<Two, [0]>;
type Five = Add<Three, [Two]>;

在这个例子中,我们使用数组的长度来表示数字,通过递归地扩展数组来实现加法运算。Zero 表示数字0,Add 类型别名通过不断将 Y 数组中的元素添加到 X 数组中来实现加法。

实现类型级别的字符串操作

在类型层面实现字符串操作也是类型体操的一个有趣应用。例如,我们可以实现字符串的拼接:

type Concat<S1 extends string, S2 extends string> =
    `${S1}${S2}`;
type Result = Concat<"hello", " world">; // "hello world"

通过模板字面量类型,我们可以在类型层面轻松实现字符串的拼接。更复杂的字符串操作,如字符串替换、截取等,也可以通过类型体操来实现,不过实现过程会更加复杂,需要结合条件类型、递归等技术。

通过以上对TypeScript中类型推导与类型体操的详细介绍,我们可以看到TypeScript的类型系统具有强大的表达能力,能够处理各种复杂的类型转换和计算问题。无论是在小型项目还是大型企业级应用中,合理运用类型推导和类型体操都能提高代码的类型安全性和可维护性。