Skip to content

在我们的编程旅程中,一个永恒的追求是编写可复用的代码。我们希望创建一个函数或一个类,它能够处理多种不同的数据类型,而不仅仅是一种。然而,在追求复用性的同时,我们如何才能不牺牲 TypeScript 提供的最大优势——类型安全呢?

这正是泛型 (Generics) 登场解决的核心问题。泛型是 TypeScript 中最强大的特性之一,它允许我们创建可以“适用于多种类型”的组件,同时还能保持完整的类型信息和安全检查。可以说,掌握泛型,是从“会用” TypeScript 到“精通” TypeScript 的关键一步。

问题的起点:any 类型的陷阱

让我们从一个简单的需求开始:编写一个 identity 函数,它接收一个参数并原封不动地返回它。

一种最直接、但也是最危险的实现方式是使用 any 类型:

typescript
function identity(arg: any): any {
  return arg;
}

let output = identity("myString"); // output 的类型是 any

这段代码虽然能工作,但我们却付出了巨大的代价:丢失了类型信息。我们传入了一个 string,但 output 变量的类型却变成了 any。这意味着,我们可以在 output 上进行任何操作(比如 output.toFixed(2)),TypeScript 编译器都不会发出任何警告,但这些操作在运行时很可能会引发错误。我们等于亲手关闭了 TypeScript 的类型保护。

我们需要一种方法,既能让函数接受任意类型,又能确保返回值的类型与传入参数的类型完全一致。

泛型的解决方案:类型变量

泛型通过引入类型变量 (Type Variables) 来解决这个问题。类型变量是一种特殊的变量,它不代表值,而是代表类型。它就像一个占位符,当我们使用该组件时,再填入具体的类型。

让我们用泛型来重写 identity 函数:

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

这段代码的变化看似微小,但意义深远。让我们来分解这个语法:

  1. <T>:这是类型变量的声明。我们告诉 TypeScript,T 是一个类型占位符。T 这个名字是惯例,你也可以用任何其他合法的变量名(如 Type, TInput 等)。
  2. arg: T:我们将参数 arg 的类型设置为 T
  3. : T:我们指定函数的返回值类型也是 T

通过这种方式,我们建立了一个契约:传入参数的类型与返回值的类型必须一致T 在函数被调用时,才被具体的类型所填充。

现在,让我们看看调用这个泛型函数时会发生什么:

typescript
// 1. 显式指定类型
let output = identity<string>("myString"); // output 的类型是 string

// 2. 利用类型推断 (更常见)
let anotherOutput = identity(123); // output 的类型是 number

在第二个例子中,我们没有显式地用 <number> 来指定类型,但 TypeScript 非常智能,它会根据我们传入的参数 123,自动推断T 应该被 number 替代。

现在,我们得到了两全其美的结果:一个可以处理任何类型的函数,并且完全保留了类型信息,类型安全得到了百分之百的保障。

泛型的广阔天地

泛型的应用远不止于函数。它可以被用于接口、类,从而构建出高度可复用且类型安全的系统。

1. 泛型接口

我们可以定义一个泛型接口来描述一个通用的数据结构,比如 API 的响应格式。

typescript
interface ApiResponse<T> {
  data: T;
  status: 'success' | 'error';
  message?: string;
}

// 定义 User 类型
interface User {
  id: number;
  name: string;
}

// 使用泛型接口
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Raycast" },
  status: "success",
};

const errorResponse: ApiResponse<null> = {
  data: null,
  status: "error",
  message: "Not found",
};

ApiResponse<T> 成为了一个可复用的模板,可以用来包裹任何类型的 data

2. 泛型类

同样,泛型也可以用于类,以创建能处理多种数据类型的容器或集合。

typescript
class DataStore<T> {
  private data: T[] = [];

  addItem(item: T): void {
    this.data.push(item);
  }

  getItems(): T[] {
    return [...this.data];
  }
}

// 创建一个只能存储数字的实例
const numberStore = new DataStore<number>();
numberStore.addItem(10);
// numberStore.addItem("hello"); // 编译时错误!

// 创建一个只能存储字符串的实例
const stringStore = new DataStore<string>();
stringStore.addItem("hello");

泛型约束:为灵活性加上“缰绳”

有时候,我们希望泛型类型 T 不是完全任意的,而是必须满足某些特定的条件。例如,我们想编写一个函数,它接收一个参数并打印其 length 属性。

一个错误的尝试是:

typescript
// function logLength<T>(arg: T): void {
//   console.log(arg.length); // 编译时错误: Property 'length' does not exist on type 'T'.
// }

编译器会报错,因为它无法保证任意类型 T 都拥有 length 属性。

为了解决这个问题,我们需要使用泛型约束 (Generic Constraints)。我们通过 extends 关键字,来要求类型变量 T 必须“继承”自某个特定的类型。

typescript
interface Lengthwise {
  length: number;
}

// T 必须是拥有 length: number 属性的类型
function logLength<T extends Lengthwise>(arg: T): void {
  console.log(arg.length);
}

logLength("hello"); // string 有 length 属性
logLength([1, 2, 3]); // array 有 length 属性
logLength({ length: 10, value: '...' }); // 对象有 length 属性
// logLength(123); // 编译时错误: number 没有 length 属性

通过 extends Lengthwise,我们与编译器达成了一个新的约定:T 可以是任何类型,但它必须符合 Lengthwise 接口的契约。这既保证了函数的灵活性,又确保了在函数内部可以安全地调用 .length

总结

泛型是 TypeScript 类型系统的精髓所在,它是在抽象能力和类型安全之间架起的一座完美桥梁。

  • 它通过类型变量 (<T>) 作为类型的占位符,让我们能够编写出与具体类型无关的可复用组件。
  • 它在编译时进行类型检查,保留了完整的类型信息,避免了 any 类型带来的风险。
  • 通过泛型约束 (extends),我们可以对类型变量的范围进行限制,确保其具备某些必要的属性或方法。

当你发现自己在为不同的数据类型编写功能相似的函数或类时,停下来想一想,这正是泛型大显身手的绝佳时机。拥抱泛型,你将能够构建出更优雅、更健壮、更具扩展性的 TypeScript 应用程序。

不知道说啥了很无语了