《TypeScript编程入门》4.3 泛型函数入门

泛型是 TypeScript 类型系统里最重要的复用机制。本节从「同一段逻辑要处理多种类型」这个朴素需求出发,讲清类型参数的含义、泛型函数的写法、类型参数的自动推断与显式指定,并重点说明泛型与 any 的本质差别——泛型保留类型信息,any 抹掉类型信息。最后给出多个类型参数、数组与对象泛型的常见模式,以及初学者最容易踩的推断陷阱。读完本节,你能为任意函数写出既不丢类型又足够通用的签名。

本节目标:理解「类型参数」是什么,能读懂并写出 <T>(x: T) => T 这样的泛型函数;掌握类型参数的自动推断规则与显式指定的时机;说清泛型与 any 的本质差别,知道为什么前者能在保留类型信息的同时实现复用。本节只讲函数上的泛型,接口与类上的泛型留到第 8 章。

4.3 泛型函数入门

上一节我们用重载解决了「同一个函数、不同参数组合」的问题。但重载有一个天花板:它要求你把每种参数类型都列出来。如果参数类型是「任意类型」,你不可能穷举——这时必须引入一个新工具:类型参数(type parameter)。

从 identity 函数说起

最简单的复用需求是「原样返回入参」。朴素做法是每种类型写一个函数——identityString、identityNumber、identityBoolean……三份代码逻辑完全相同,只是类型不同。用 any 能一行搞定,但代价是类型信息丢失:

function identityAny(value: any): any {
  return value;
}
const s = identityAny("hello");
// s 的类型是 any —— 后面所有类型检查都失效了
s.toUpperCase(); // 不会报错,但也没有任何保障

any 的问题不在于「能通过编译」,而在于它把检查的责任推回给了你。更好的写法是让编译器记住「传进去什么,就返回什么」:

function identity<T>(value: T): T {
  return value;
}
const s = identity("hello"); // s: string
const n = identity(42); // n: number
const arr = identity([1, 2, 3]); // arr: number[]

这里的 <T> 就是类型参数。它像函数参数一样,只不过接受的是类型而不是值。理解它的关键比喻是:

普通参数类型参数
声明位置function f(x: number)function f<T>(x: T)
传入时机调用时传值调用时传类型(或由编译器推断)
作用范围函数体内部签名的所有类型位置
谁提供调用方调用方或编译器

类型参数是调用方的类型「占位符」——函数作者不知道具体是什么类型,但可以保证「输入和输出用同一个」。

泛型函数的语法

把泛型函数的语法拆开看:

function identity<T>(value: T): T {
  //              ↑        ↑
  //        参数用 T     返回值也用 T
  return value;
}

<T> 写在函数名之后、参数列表之前。T 只是一个名字,你可以叫它任何合法的标识符,但社区约定俗成:

  • T —— Type,最通用;
  • K —— Key,键的类型;
  • V —— Value,值的类型;
  • E —— Element,元素类型;
  • R —— Result,返回类型。

多个类型参数用逗号分隔:<T, K, V>。不要用单字母之外的命名去装深奥,但也不要为了简短牺牲可读性——像 TInput、TOutput 这种名字在复杂签名里反而更好。

箭头函数写泛型时有一个坑:.tsx 文件里 <T> 会被 JSX 解析器误认为是标签。解决办法是在类型参数后加一个逗号:

// 在 .ts 文件里没问题
const identity = <T>(value: T): T => value;

// 在 .tsx 文件里必须这样写,逗号是为了消除歧义
const identityTsx = <T,>(value: T): T => value;

这个逗号不影响语义,只是给解析器一个「这是类型参数列表,不是 JSX」的信号。用 React 的同学一定会遇到它。

类型参数推断:编译器帮你填

调用泛型函数时,你通常不需要写类型参数,编译器能从实参推断出来:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const a = first([1, 2, 3]); // T 推断为 number,返回 number | undefined
const b = first(["x", "y"]); // T 推断为 string
const c = first([]); // T 推断为 never,返回 never

注意第三个例子:空数组让 T 推断成了 never。这是合理的——既然数组里没有任何元素,元素类型就只能是「不可能存在的类型」。never 会向上传播,如果你后续要用 c,就得先处理这个情况。

推断的基本规则是:从实参到形参做「类型匹配」。看一个稍复杂的例子:

function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}

pair(1, 2); // T = number
pair("a", "b"); // T = string
pair(1, "b"); // T = number | string —— 取候选类型的并集

最后一个例子值得注意:当同一个类型参数出现在多个位置、而实参类型不一致时,TypeScript 会取候选类型的联合,而不是报错。这是泛型推断里最容易被误解的一点。

显式指定类型参数

有时候推断结果不是你想要的,或者编译器根本推不出来。此时可以显式指定:

// 推断结果太窄:想要 (string | number)[] 而不是 string[]
const mixed = pair<string | number>("a", 1);

// 从实参完全推不出来
function createArray<T>(length: number, value: T): T[] {
  return Array.from({ length }, () => value);
}

// 推断:T = number
const nums = createArray(3, 0); // number[]

// 显式指定,用于「实参推不出」或「想要更宽的类型」
const empty = createArray<string>(3, ""); // string[]

什么时候必须显式指定? 三个信号:

  1. 实参中没有能推断出类型参数的位置(比如 createArray(3) 只传了长度);
  2. 推断出的类型太窄,导致后续赋值失败;
  3. 你想让调用方(或读者)明确看到类型意图。

还要记住一点:TypeScript 不支持「部分推断 + 部分显式」——要么全部由编译器推断,要么把类型参数全部写出来。这是它与其他语言的一个差异。

泛型与 any 的本质差别

这是本节最重要的一节。看两个函数的对比:

// 版本 A:any
function firstAny(arr: any[]): any {
  return arr[0];
}

// 版本 B:泛型
function firstGeneric<T>(arr: T[]): T | undefined {
  return arr[0];
}

const users = [{ name: "Alice", age: 30 }];

const u1 = firstAny(users);
u1.name.toUpperCase(); // 能编译,但如果 arr 是数字数组就会运行时报错

const u2 = firstGeneric(users);
u2.name; // Error: 'u2' is possibly 'undefined'.
if (u2 !== undefined) {
  u2.name.toUpperCase(); // OK
}

差别可以用一句话概括:any 是「放弃检查」,泛型是「延迟检查」。any 在定义时就放弃了信息,调用方拿到它之后做什么都不会被拦;泛型在定义时保留了一个「待定类型」T,调用时才由编译器确定,且这个精确类型由调用方享受。用一张表总结:

维度any泛型 <T>
类型信息丢失保留并传递
调用方得到的类型any精确类型(如 string)
拼写错误不报错报错
属性访问任意属性都允许只有 T 已知的属性允许
何时该用迁移遗留 JS、类型确实不可知绝大多数「通用」场景

还有一个关键区别:泛型函数体内部不能随意操作 T。

function bad<T>(value: T): number {
  // Error: Property 'length' does not exist on type 'T'.
  return value.length;
}

因为 T 可能是 number、boolean 等任何类型,编译器不允许你假设它有 length。这正是泛型的价值所在——它逼你说清楚「我需要 T 满足什么条件」。这个条件用 extends 表达,也就是泛型约束,我们在 8.1 泛型约束(extends) 里详细讲。

多个类型参数

当一个函数涉及两种以上的类型关系时,用多个类型参数:

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

const lengths = map(["a", "bb", "ccc"], (s) => s.length);
// T = string, U = number → number[]

这里的 fn: (item: T) => U 是关键:它把「输入类型」和「输出类型」解耦了。注意 (s) => s.length 里 s 不需要写类型注解——它从 T 被推断为 string。

再看一个更实用的例子,实现「按键取值」:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

interface Product {
  id: number;
  title: string;
  price: number;
}

const products: Product[] = [
  { id: 1, title: "键盘", price: 299 },
  { id: 2, title: "鼠标", price: 99 },
];

const titles = pluck(products, "title"); // string[]
const prices = pluck(products, "price"); // number[]

// Error: Argument of type '"stock"' is not assignable to parameter of type
// '"id" | "title" | "price"'.
pluck(products, "stock");

K extends keyof T 是泛型约束的用法,它保证 key 必须是 T 的合法键,并且返回值类型 T[K] 精确到该键的值类型。这是「类型安全 + 复用」结合得最漂亮的一个例子,也是很多 ORM、状态管理库的核心签名。第 9 章会专门展开 keyof 与索引访问类型。

泛型在数组与对象上的常见模式

除了单个值,泛型更常用于「容器」:

// 1. 包装成统一响应结构
interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

function ok<T>(data: T): ApiResponse<T> {
  return { code: 0, data, message: "ok" };
}

const r1 = ok({ id: 1, name: "Alice" });
// ApiResponse<{ id: number; name: string }>
const r2 = ok([1, 2, 3]);
// ApiResponse<number[]>

注意 ApiResponse<T> 这种写法——T 出现在类型的位置,这就是泛型从「函数」延伸到「接口/类型别名」的入口。第 8 章会系统讲它,这里先建立直觉即可。

// 2. 分组:按回调返回的键把数组归类
function groupBy<T, K extends string>(items: T[], getKey: (item: T) => K): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) (result[getKey(item)] ??= []).push(item);
  return result;
}

const grouped = groupBy(products, (p) => (p.price > 200 ? "expensive" : "cheap"));
// Record<"expensive" | "cheap", Product[]>
console.log(grouped.expensive.length); // 1

这里 K extends string 保证了 getKey 返回的能用作对象键,同时 Record<K, T[]> 让结果对象的键被精确推断为 "expensive" | "cheap"——访问 grouped.typo 会直接报错。

常见坑与排查

坑一:泛型参数只出现在返回值里。 这时编译器无法推断,只能显式指定:

// T 没有出现在任何形参中,调用时必然推断失败
function emptyArray<T>(): T[] {
  return [];
}

const a = emptyArray(); // Error: Expected 1 type argument, but got 0.
const b = emptyArray<string>(); // 正确:显式指定,b 的类型是 string[]

坑二:把 T 用在「需要具体类型」的地方。

function stringify<T>(value: T): string {
  // Error: This comparison appears to be unintentional because the types 'T'
  // and 'string' have no overlap.
  if (value === "hello") return value;
  return JSON.stringify(value);
}

编译器认为 T 和 "hello" 没有重叠——因为 T 可以是任何类型。修复方式是加约束:<T extends string>。

坑三:误以为泛型能改变运行时行为。 泛型是纯类型层面的机制,编译后会被完全擦除:

function identity<T>(value: T): T {
  return value;
}
// 编译产物(JavaScript)里 T 完全消失:
// function identity(value) { return value; }

因此你不能在运行时判断 T 是什么——下面这段代码会被直接拒绝:

function isString<T>(value: T): boolean {
  // Error: 'T' only refers to a type, but is being used as a value here.
  return typeof value === T;
}

需要运行时类型信息时,只能传入一个「类型守卫函数」或构造函数——这正是我们在 7.3 类型守卫与控制流分析 里讨论的模式。关于类型擦除带来的运行时盲区,3.3 any·unknown·never·void 与类型断言 也提到过,后续 13.1 节会专门展开。

坑四:过度泛型。 如果一个函数只会被一种类型调用,就不该泛型化:

// 过度设计:T 永远只会是 User
function getName<T extends { name: string }>(user: T): string {
  return user.name;
}

// 更简单
function getName2(user: { name: string }): string {
  return user.name;
}

判断标准:泛型只在「调用方需要保留具体类型」时才有价值。如果所有调用方得到的类型都一样,直接写具体类型更清晰。

泛型让「通用」不再等于「不安全」

回到本节的出发点。我们在 3.3 节见过 any 的便利与危险,在 4.2 节见过重载的精确与笨重。泛型恰好站在两者之间:

  • 比重载更通用——不需要穷举类型;
  • 比 any 更安全——类型信息一路传递到调用方;
  • 代价是学习曲线——需要理解「类型参数」这个新概念,以及后面的约束、默认值、条件类型。

一个成熟判断:当你在签名里写下 any 时,先问一句「这里能不能用泛型」。多数情况下答案是能。这也是为什么像 Lodash、RxJS、TanStack Query 这类库的公开 API 几乎全是泛型签名——它们既要通用,又要让调用方拿到精确类型。如果你对真实项目里如何用泛型设计 API 感兴趣,可以延伸阅读本站专题文章 泛型 API 设计与性能 。

小结

这一节我们建立了泛型的第一层认知:

  1. 类型参数是类型的占位符。<T> 让函数作者可以「不知道具体类型」却仍能表达「输入输出是同一个类型」,这是 any 做不到的。
  2. 推断优先,显式补充。绝大多数调用不需要写类型参数;只有当实参推不出、推断太窄或需要表明意图时,才显式指定 <T>。注意 TypeScript 不支持部分推断。
  3. 泛型 ≠ any。any 放弃检查,泛型延迟检查并保留信息。泛型函数体内部不能随意操作 T,这正是它逼你说清约束的地方——约束用 extends 表达,是第 8 章的主题。
  4. 泛型只在需要保留具体类型时才有价值。过度泛型会让签名更难读,判断标准是「调用方是否受益」。

到这里,第 4 章「函数与作用域」就完整了:4.1 讲了签名与参数的各种形态,4.2 讲了重载与 this 的类型行为,4.3 讲了泛型函数。三者合起来,你已经能写出对调用方友好、对实现方严格的函数签名。下一章 5.1 interface 定义对象结构 开始进入对象世界——从 interface 如何描述一个对象的结构讲起,而本节看到的 ApiResponse<T> 这类泛型接口,会在第 8 章集中展开。

阅读导航:上一节:4.2 重载、this 类型与箭头函数 · 下一节:5.1 interface 定义对象结构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes