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

探索TypeScript类型系统的方法

2024-10-215.4k 阅读

一、TypeScript 类型系统基础

  1. 基本类型 TypeScript 支持一系列基本类型,这些类型构成了类型系统的基础。最常见的基本类型包括 booleannumberstring。例如:
let isDone: boolean = false;
let myNumber: number = 42;
let myString: string = "Hello, TypeScript!";

这里通过显式的类型注解,清晰地定义了变量的数据类型。在 TypeScript 中,也可以让编译器根据初始值自动推断类型,如:

let inferredBoolean = false; // 自动推断为 boolean 类型
let inferredNumber = 42; // 自动推断为 number 类型
let inferredString = "Hello, inferred!"; // 自动推断为 string 类型

除了上述常见类型,还有 nullundefined,它们分别表示空值和未定义值。默认情况下,它们是所有类型的子类型。例如:

let nullableNumber: number | null = null;
let undefValue: undefined = undefined;

void 类型表示没有任何类型,通常用于函数返回值,表示该函数不返回任何值:

function logMessage(message: string): void {
    console.log(message);
}

never 类型表示永远不会有值的类型。比如,一个函数永远抛出异常或者永远不会返回:

function throwError(message: string): never {
    throw new Error(message);
}
  1. 类型别名 类型别名是给类型起一个新名字,通过 type 关键字来定义。这在简化复杂类型定义或者复用类型时非常有用。例如,假设我们有一个表示坐标点的类型:
type Point = {
    x: number;
    y: number;
};

let myPoint: Point = { x: 10, y: 20 };

类型别名也可以用于联合类型和交叉类型。联合类型表示一个值可以是多种类型中的一种,交叉类型表示一个值必须同时满足多种类型。

type StringOrNumber = string | number;
let value: StringOrNumber = 10;
value = "Hello";

type Admin = {
    name: string;
    role: "admin";
};

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

type AdminUser = Admin & User;
let adminUser: AdminUser = { name: "John", role: "admin", age: 30 };

二、深入类型推断

  1. 函数参数和返回值推断 TypeScript 编译器在函数定义时会进行类型推断。对于函数参数,编译器会根据传入的实际值推断参数类型。例如:
function add(a, b) {
    return a + b;
}

let result = add(1, 2); // 这里 a 和 b 会被推断为 number 类型,result 也会被推断为 number 类型

在函数返回值方面,如果函数体内有明确的返回语句,编译器会根据返回值推断函数的返回类型。

function getMessage() {
    return "This is a message";
}

let message = getMessage(); // message 被推断为 string 类型

然而,在某些情况下,显式地声明函数的返回类型是必要的,特别是在函数内部逻辑复杂,编译器难以准确推断时。比如递归函数:

function factorial(n: number): number {
    if (n <= 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}
  1. 上下文类型推断 上下文类型推断是指 TypeScript 编译器根据变量使用的上下文来推断其类型。例如,在事件处理函数中:
document.addEventListener("click", function (event) {
    // 这里 event 会被推断为 MouseEvent 类型,因为 addEventListener 的第一个参数是一个处理 MouseEvent 的函数
    console.log(event.clientX);
});

再比如,在赋值语句中,如果右边表达式的类型已知,左边变量会被推断为相同类型:

let myArray = [1, 2, 3]; // myArray 被推断为 number[] 类型

三、类型兼容性

  1. 结构类型系统 TypeScript 使用结构类型系统,这意味着只要两个类型的结构兼容,它们就是兼容的,而不要求它们具有相同的类型定义。例如:
interface Animal {
    name: string;
}

interface Dog {
    name: string;
    breed: string;
}

let myAnimal: Animal = { name: "Buddy" };
let myDog: Dog = { name: "Buddy", breed: "Golden Retriever" };

myAnimal = myDog; // 这是允许的,因为 Dog 类型的结构包含了 Animal 类型的所有属性

在函数参数方面,结构类型系统也起作用。如果函数参数类型是一个接口,传入的对象只要具有接口要求的属性,就可以被接受:

function greet(animal: Animal) {
    console.log(`Hello, ${animal.name}`);
}

greet(myDog); // 这是合法的,因为 myDog 满足 Animal 接口的结构
  1. 函数类型兼容性 对于函数类型,参数和返回值的兼容性都遵循一定规则。在参数方面,目标函数(被赋值的函数)的参数可以比源函数(赋值的函数)的参数更宽松。例如:
let sourceFunc = function (a: number) {
    return a;
};

let targetFunc: (a: number | string) => number = sourceFunc; // 这是允许的,因为 targetFunc 的参数类型更宽松

在返回值方面,源函数的返回值类型必须与目标函数的返回值类型兼容或者是其子类型。例如:

let sourceReturn = function (): number {
    return 1;
};

let targetReturn: () => number | string = sourceReturn; // 这是允许的,因为 number 是 number | string 的子类型

四、泛型

  1. 泛型函数 泛型允许我们创建可复用的组件,这些组件可以工作于多种类型之上,而不是特定类型。以一个简单的 identity 函数为例,它返回传入的参数:
function identity<T>(arg: T): T {
    return arg;
}

let result1 = identity<number>(42); // 明确指定类型参数为 number
let result2 = identity("Hello"); // 编译器自动推断类型参数为 string

在泛型函数中,类型参数 T 是一个占位符,它可以代表任何类型。我们可以在函数参数和返回值中使用这个类型参数。 2. 泛型接口和类 泛型也可以应用于接口和类。定义一个泛型接口:

interface Container<T> {
    value: T;
    getValue(): T;
}

class Box<T> implements Container<T> {
    constructor(public value: T) {}
    getValue(): T {
        return this.value;
    }
}

let numberBox = new Box<number>(42);
let stringBox = new Box<string>("Hello");

这里 Container 接口和 Box 类都使用了泛型类型参数 T,使得它们可以用于不同类型的值。 3. 泛型约束 有时候,我们需要对泛型类型参数进行约束,以确保其具有某些特定的属性或方法。例如,假设我们要定义一个函数,它接受一个具有 length 属性的对象,并返回其长度:

interface HasLength {
    length: number;
}

function getLength<T extends HasLength>(arg: T): number {
    return arg.length;
}

let strLength = getLength("Hello"); // 合法,string 类型具有 length 属性
let arrayLength = getLength([1, 2, 3]); // 合法,数组类型具有 length 属性

这里通过 T extends HasLength 对泛型类型参数 T 进行了约束,只有实现了 HasLength 接口的类型才能作为 T 的实际类型。

五、条件类型

  1. 基本条件类型 条件类型允许我们根据类型关系选择不同的类型。其语法类似于 JavaScript 中的三元运算符。例如,定义一个 If 条件类型:
type If<C, T, F> = C extends true? T : F;

type Result1 = If<true, string, number>; // Result1 为 string 类型
type Result2 = If<false, string, number>; // Result2 为 number 类型

在实际应用中,条件类型常用于根据不同的类型条件选择不同的实现。例如,在处理数组时,我们可能需要根据数组元素类型进行不同的操作:

type ElementType<T> = T extends Array<infer U>? U : never;

type StringArray = string[];
type ElementTypeOfStringArray = ElementType<StringArray>; // string 类型

type NonArray = number;
type ElementTypeOfNonArray = ElementType<NonArray>; // never 类型

这里 ElementType 条件类型使用了 infer 关键字,它用于在条件类型中推断出一个类型。 2. 分布式条件类型 分布式条件类型是指在条件类型中使用裸类型参数时,当传入联合类型时,条件类型会自动分发到联合类型的每个成员上。例如:

type ToArray<T> = T extends any? T[] : never;

type Union = string | number;
type UnionToArray = ToArray<Union>; // string[] | number[]

这里 ToArray 是一个分布式条件类型,当传入 Union 联合类型时,它会分别对 stringnumber 应用条件,最终得到 string[] | number[]

六、映射类型

  1. 基本映射类型 映射类型允许我们基于现有的类型创建新类型,通过对现有类型的属性进行遍历和转换。例如,假设我们有一个简单的接口:
interface User {
    name: string;
    age: number;
}

type ReadonlyUser = {
    readonly [P in keyof User]: User[P];
};

let readonlyUser: ReadonlyUser = { name: "John", age: 30 };
// readonlyUser.name = "Jane"; // 这会报错,因为 ReadonlyUser 的属性是只读的

这里通过 keyof User 获取 User 接口的所有属性键,然后使用 P in keyof User 对每个属性键进行遍历,readonly [P in keyof User]: User[P] 表示创建一个新类型,其属性与 User 相同,但都是只读的。 2. 映射类型的修改器 除了 readonly,我们还可以使用 ? 修改器来创建可选属性的映射类型。例如,将 User 接口的所有属性变为可选:

type OptionalUser = {
    [P in keyof User]?: User[P];
};

let optionalUser: OptionalUser = {}; // 合法,因为所有属性都是可选的

另外,我们还可以通过 ExcludeExtract 等工具类型结合映射类型进行更复杂的类型转换。例如,排除 User 接口中的 age 属性:

type ExcludeUserAge = {
    [P in Exclude<keyof User, "age">]: User[P];
};

let excludeUserAge: ExcludeUserAge = { name: "John" };

七、工具类型

  1. 预定义工具类型 TypeScript 提供了一系列预定义的工具类型,方便我们进行常见的类型转换。比如 Partial,它将一个类型的所有属性变为可选:
interface Product {
    name: string;
    price: number;
}

let partialProduct: Partial<Product> = {}; // 合法,所有属性变为可选

Required 则与 Partial 相反,它将一个类型的所有可选属性变为必选:

interface OptionalProduct {
    name?: string;
    price?: number;
}

let requiredProduct: Required<OptionalProduct> = { name: "Widget", price: 10 };

Readonly 工具类型用于创建一个只读类型,与我们前面手动创建的只读映射类型类似:

let readonlyProduct: Readonly<Product> = { name: "Gadget", price: 20 };
// readonlyProduct.price = 25; // 这会报错,因为属性是只读的
  1. 自定义工具类型 我们也可以根据需要自定义工具类型。例如,定义一个工具类型,将一个类型的所有属性值变为字符串类型:
type StringifyProperties<T> = {
    [P in keyof T]: string;
};

interface Data {
    value1: number;
    value2: boolean;
}

type StringifiedData = StringifyProperties<Data>;
// StringifiedData 类型为 { value1: string; value2: string; }

通过自定义工具类型,可以更灵活地满足项目中特定的类型转换需求。

八、类型体操

  1. 类型递归 类型递归是在类型定义中使用自身的一种技术,常用于处理复杂的数据结构,如树或链表。例如,定义一个表示树结构的类型:
interface TreeNode<T> {
    value: T;
    children?: TreeNode<T>[];
}

let tree: TreeNode<number> = {
    value: 1,
    children: [
        { value: 2 },
        { value: 3, children: [{ value: 4 }] }
    ]
};

这里 TreeNode 类型在 children 属性中递归地引用了自身,从而可以表示任意深度的树结构。 2. 复杂类型推导 在一些场景下,我们需要进行复杂的类型推导。比如,从一个嵌套的对象类型中提取出特定路径的类型。假设我们有一个嵌套对象:

interface DeepObject {
    a: {
        b: {
            c: string;
        };
    };
}

我们可以定义一个工具类型来提取特定路径的类型:

type GetPath<T, K extends keyof T> = T[K];
type GetNestedPath<T, K1 extends keyof T, K2 extends keyof T[K1]> = T[K1][K2];

type PathType = GetNestedPath<DeepObject, "a", "b">; // { c: string; }

通过这种方式,可以实现复杂的类型推导,满足特定的业务需求。

通过对以上各个方面的深入探索,我们能够更好地理解和运用 TypeScript 的类型系统,编写出更健壮、可维护的代码。无论是在小型项目还是大型企业级应用中,熟练掌握 TypeScript 类型系统都能显著提升开发效率和代码质量。