TypeScript 泛型基础 — 类型参数化,第一次让类型"流动"起来
函数让【值】可以复用逻辑,泛型让【类型】可以复用结构。今天你将第一次在类型的位置写下"变量"——
<T>。它不是 any 的换装,而是"占位待填"的承诺:写函数时不知道类型,调用时类型自动确定,全程类型信息不丢失。这是 TS 从"标注工具"进化为"运算系统"的第一步。
目录
一、类型世界的重复代码
1.1 你已经经历过"值的重复"
阶段1 学函数时,第一课就是 DRY(Don’t Repeat Yourself):
// 三个几乎一样的函数 —— 值层面的重复
function sumNums(a: number, b: number) { return a + b; }
function sumStrs(a: string, b: string) { return a + b; }
// 函数解决:把"变的部分"提取成【参数】
function combine<T>(a: T, b: T) { /* ... */ }
提取参数的本质:把变化的抽出去,把不变的留下来。
1.2 类型层面的重复(今天的起点)
// 大屏项目里,"包裹一个值"的需求到处都是:
/** 数字的包裹 */
interface NumberBox {
value: number;
}
/** 字符串的包裹 */
interface StringBox {
value: string;
}
/** 设备数据的包裹 */
interface DeviceBox {
value: DeviceData;
}
/** 告警的包裹 */
interface AlertBox {
value: AlertRecord;
}
// ⚠ 四个接口,除了 value 的类型,结构完全一样
// 每加一种数据,就要复制粘贴一份 —— 类型层面的重复代码
1.3 泛型的答案
// 把"变的类型"抽出去 —— 类型参数 T
interface Box<T> {
value: T;
}
// 使用时"填充"类型参数(术语:实例化)
const n: Box<number> = { value: 65 };
const s: Box<string> = { value: "CNC-001" };
const d: Box<DeviceData> = { value: device };
const a: Box<AlertRecord> = { value: alert };
// 四个接口 → 一个接口。新增数据类型?再"填"一次而已。
一句话定义:泛型 = 类型层面的函数。Box<T> 就像一个"类型工厂"——传入 number,产出 { value: number }。
二、第一个泛型函数
2.1 基本语法
/**
* identity 函数:原样返回输入(最简单也最经典的泛型示例)
* <T> 声明类型参数;T 可以在参数、返回值、函数体内使用
*/
function identity<T>(value: T): T {
return value;
}
// 调用:尖括号"填入"类型
const a = identity<string>("hello"); // a: string
const b = identity<number>(65); // b: number
逐部分拆解:
function identity<T>(value: T): T {
↑ ↑ ↑
类型参数 参数用 T 返回值用 T
(占位符) (填什么类型由调用决定)
identity<string>("hello")
↑
实例化:T = string
→ (value: string): string
2.2 类型参数的命名约定
// 单字母是惯例(简洁优先),常见命名:
// T —— Type(最通用)
// K —— Key(键)
// V —— Value(值)
// E —— Element(元素)
// R —— Return(返回值)
// S / U —— 第二、第三个类型
// 复杂签名用全名单(可读性优先,官方源码也这样):
function transform<TSource, TResult>(input: TSource): TResult { /* ... */ }
2.3 函数体内 T 是"未知类型"
function first<T>(arr: T[]): T {
return arr[0]; // ✅ 返回数组元素,类型匹配 T[]
}
function bad<T>(value: T): T {
console.log(value.length); // ❌ 编译错误:T 上不存在 length
// T 可能是任何类型 —— number 没有 length
// 想访问成员?明天学"泛型约束"(extends)
return value;
}
重要认知:类型参数默认是未知类型(比 any 更严格)——它什么成员都不让访问,逼你只做"对所有类型都成立"的操作。安全性从源头开始。
2.4 T 出现在多个位置:一次绑定,处处一致
/**
* 交换二元组:T 和 U 各自保持身份
*/
function swap<T, U>(pair: [T, U]): [U, T] {
return [pair[1], pair[0]];
}
const swapped = swap(["CNC-001", 65]);
// swapped: [number, string]
// 位置 0 原来是 string,交换后是 number —— 类型跟着值一起"换了位置"
const [temp, id] = swap(["CNC-001", 65]);
// temp: number ✅ id: string ✅ —— 解构出的类型也精确
泛型的核心承诺:同一个 T 在签名里出现的所有位置,指的是同一个类型——这就是"类型信息全程不丢失"的机制保障。
三、类型推断:让 TS 自己算
3.1 推断规则
function identity<T>(value: T): T {
return value;
}
// 推断:从实参反向计算 T
const a = identity(65); // T = number(65 是 number)
const b = identity("hello"); // T = string
const c = identity(device); // T = DeviceData
const d = identity([1, 2, 3]); // T = number[]
// ✅ 推荐:绝大多数调用让 TS 推断,不用手写 <类型>
推断的方向:类型参数从实参反推——你传 65,T 就是 number。就像函数参数不需要"声明值",泛型参数大多数时候也不需要"声明类型"。
3.2 什么时候必须显式指定?
// 场景:类型参数只出现在【返回值】位置 —— 实参推不出 T
function makePair<T>(): [T, T] {
return [undefined as unknown as T, undefined as unknown as T];
}
const p = makePair<number>(); // ✅ 必须显式:没有实参可推断
// 真实场景:API 请求封装(url 不含类型信息,data 的类型必须手填)
async function request<T>(url: string): Promise<T | null> {
const res = await fetch(url);
return res.ok ? (await res.json()) as T : null;
}
const devices = await request<Device[]>("/api/devices"); // ✅ 显式 T
// 注意:这里的 T 其实是"断言"——fetch 结果的真实类型 TS 无法验证
// 真正的校验要靠守卫(第 4 天知识:类型描述期望,守卫保证现实)
3.3 推断的局限:字面量会被拓宽
function identity<T>(value: T): T { return value; }
const a = identity(65); // T = number(65 被拓宽)
const b = identity("hello"); // T = string
// 想要字面量类型?显式指定或用 as const
const c = identity<"running">("running"); // T = "running"
const d = identity("running" as const); // T = "running"
四、泛型与 any 的本质区别
4.1 对照实验
// ===== 版本 1:any =====
function firstAny(arr: any[]): any {
return arr[0];
}
const a = firstAny([65, 42, 30]);
a.toUpperCase(); // ✅ 编译通过 —— 运行时崩溃(number 没有 toUpperCase)
a.foo.bar.baz(); // ✅ 编译通过 —— 崩得更彻底
// ===== 版本 2:泛型 =====
function first<T>(arr: T[]): T {
return arr[0];
}
const b = first([65, 42, 30]); // b: number
b.toUpperCase(); // ❌ 编译错误:number 没有 toUpperCase
b.toFixed(1); // ✅ number 的方法,精确提示
4.2 本质区别
4.3 为什么泛型比函数重载更优雅
// 需求:接收 number 或 string,返回同类型
// 方案 1:重载 —— 两份签名
function process(value: number): number;
function process(value: string): string;
function process(value: number | string): number | string {
return value;
}
// ⚠ 返回类型是 number | string —— 调用方要收窄!
const r1 = process(65);
r1.toFixed(1); // ❌ 报错:r1 是 number | string
// 方案 2:泛型 —— 一份签名,类型联动
function process2<T extends number | string>(value: T): T {
return value;
}
const r2 = process2(65); // r2: number
r2.toFixed(1); // ✅
const r3 = process2("hello"); // r3: string
r3.toUpperCase(); // ✅
// (T extends 约束明天详解,今天先感受"类型跟着实参走")
结论:重载表达"几种固定组合",泛型表达"类型的函数关系"。能泛型就别重载。
五、两个以上类型参数
5.1 键值对:toDict
/**
* 数组转字典(键值类型分别参数化)
* 现实场景:设备列表 → 以 id 为键的查找表
*/
function toDict<T, K extends string>(
arr: T[],
getKey: (item: T) => K
): Record<K, T> {
const dict = {} as Record<K, T>;
for (const item of arr) {
dict[getKey(item)] = item;
}
return dict;
}
const devices: Device[] = [
{ id: "CNC-001", name: "机床", temp: 65 },
{ id: "AGV-002", name: "搬运车", temp: 42 }
];
const byId = toDict(devices, d => d.id);
// byId: Record<string, Device>
byId["CNC-001"].temp; // ✅ 65 —— O(1) 查找
5.2 转换函数:map 的类型签名
/**
* 手写 map(体会 Array<T>.map 的真实签名)
* T:输入元素类型;R:输出元素类型
*/
function myMap<T, R>(arr: T[], fn: (item: T, index: number) => R): R[] {
const result: R[] = [];
arr.forEach((item, i) => result.push(fn(item, i)));
return result;
}
// T = Device,R = string(从回调自动推断!)
const names = myMap(devices, d => d.name); // string[]
const temps = myMap(devices, d => d.temp.toFixed(1)); // string[]
const briefs = myMap(devices, d => ({ id: d.id, temp: d.temp })); // { id: string; temp: number }[]
// ⭐ 推断链:实参 devices → T = Device;回调返回类型 → R = ...
// 这就是为什么 IDE 能自动补全 d. 的所有字段,又能精确知道 map 的结果类型
5.3 回调参数的类型流转
// 泛型让"回调参数类型"由数据决定 —— 不需要手写
myMap(devices, (d, i) => {
// d: Device ✅(从 T 推断)
// i: number ✅(签名里写死的 index: number)
return `${i}: ${d.name}`;
});
六、箭头函数与接口中的泛型
6.1 箭头函数的泛型
// 箭头函数语法:泛型在前
const identity = <T>(value: T): T => value;
// ⚠ .tsx 文件里的坑:<T> 会被当成 JSX 标签!
// .tsx 中必须写成 <T,>(加逗号)或 extends unknown:
const identityTsx = <T extends unknown>(value: T): T => value;
// (Vue3 + TS 项目全是 .tsx/.vue,这个坑记住)
6.2 泛型接口
/** 分页结构 —— 任何列表接口的通用形状 */
interface Page<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// 设备分页
const devicePage: Page<Device> = {
list: devices,
total: 128,
page: 1,
pageSize: 20
};
devicePage.list[0].temp; // ✅ 元素类型精确
// 告警分页 —— 同一个接口
const alertPage: Page<AlertRecord> = { list: [alert], total: 1, page: 1, pageSize: 20 };
alertPage.list[0].level; // ✅
6.3 类型别名中的泛型
// type 同样支持泛型(与 interface 等价选择,见第 2 天)
type ApiResponse<T> = {
code: number;
message: string;
data: T;
};
type DeviceListRes = ApiResponse<Device[]>;
// { code: number; message: string; data: Device[] }
6.4 泛型类型别名 vs 泛型函数
// ⚠ 分清:泛型【类型】和泛型【函数】是两个世界的东西
// 泛型类型(类型的工厂)
type Box<T> = { value: T };
const b: Box<number> = { value: 1 }; // 类型位置使用
// 泛型函数(值的工厂,带类型参数)
function makeBox<T>(value: T): Box<T> { // 值位置使用
return { value };
}
const mb = makeBox(65); // Box<number>
七、实战场景全覆盖
7.1 场景一:API 请求封装(大屏项目最真实的泛型)
/**
* 统一请求封装:每个接口只声明 data 的类型
*/
async function request<T>(
url: string,
options?: RequestInit
): Promise<T | null> {
try {
const res = await fetch(url, options);
if (!res.ok) return null; // HTTP 层失败(D14 知识)
const json = (await res.json()) as ApiResponse<T>;
if (json.code !== 0) {
console.error("[api]", json.message);
return null;
}
return json.data;
} catch (err) {
console.error("[api] 网络错误", err);
return null;
}
}
// 每个接口一行类型,data 精确:
const devices = await request<Device[]>("/api/devices");
const stats = await request<DeviceStats>("/api/stats");
const alerts = await request<Page<AlertRecord>>("/api/alerts");
if (devices) {
devices[0].temp; // ✅ Device 的字段
}
if (alerts) {
alerts.total; // ✅ 分页元信息
}
7.2 场景二:数组工具集
/**
* 数组去重
*/
function unique<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
/**
* 数组分块(分页渲染用)
*/
function chunk<T>(arr: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
/**
* 计数统计(按回调分组计数)
*/
function countBy<T>(arr: T[], fn: (item: T) => string): Record<string, number> {
const result: Record<string, number> = {};
for (const item of arr) {
const key = fn(item);
result[key] = (result[key] ?? 0) + 1;
}
return result;
}
// ===== 使用 =====
const ids = unique(["CNC-001", "AGV-002", "CNC-001"]); // string[](不是 any[])
const pages = chunk(devices, 6); // Device[][](每页 6 个)
const byStatus = countBy(devices, d => d.status); // Record<string, number>
// { running: 2, standby: 1 }
7.3 场景三:防抖节流(阶段1 知识的泛型升级)
/**
* 防抖:保持原函数的参数类型(第 16 周防抖的 TS 完全体)
* ...args: Parameters<F> —— 明天预告,先看效果
*/
function debounce<F extends (...args: any[]) => any>(
fn: F,
delay: number
): (...args: Parameters<F>) => void {
let timer: number | undefined;
return (...args: Parameters<F>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay) as unknown as number;
};
}
function search(keyword: string, scope?: "all" | "current") { /* ... */ }
const debounced = debounce(search, 300);
debounced("CNC", "all"); // ✅ 参数类型与原函数完全一致
debounced(123); // ❌ 编译错误
7.4 场景四:localStorage 序列化封装
/**
* 类型安全的存储封装:存的什么类型,取的就是什么类型
*/
function storage<T>(key: string): {
set: (value: T) => void;
get: () => T | null;
} {
const fullKey = `monitor:${key}`;
return {
set(value) {
localStorage.setItem(fullKey, JSON.stringify(value));
},
get() {
const raw = localStorage.getItem(fullKey);
if (raw === null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
};
}
// 使用:一个 key 绑定一个类型
const themeStore = storage<"dark" | "light">("theme");
themeStore.set("dark");
const theme = themeStore.get(); // "dark" | "light" | null ✅
八、类比记忆:快递单 vs 万能箱
口诀:any 是黑箱,泛型是面单;推断能填就不手填。
九、常见坑点与最佳实践
坑点 1:泛型参数未被使用
// ❌ T 声明了却没用 —— 编译警告,纯装饰
function bad<T>(value: string): string {
return value;
}
// ✅ 每个类型参数都必须出现在签名中起作用
function good<T>(value: T): T { return value; }
坑点 2:不必要的手动指定
// ❌ 多余:推断得出来
const a = identity<number>(65);
// ✅ 让 TS 自己算
const b = identity(65);
坑点 3:函数体内对 T 为所欲为
function bad<T>(value: T): string {
return value.toString(); // ✅ 合法(所有类型都有 toString)
// return value.length; // ❌ T 上没有 length
// return value + 1; // ❌ T 不保证能运算
}
// 原则:T 只能做"对所有类型都成立"的事 —— 想做更多,上约束(明天)
坑点 4:.tsx 里的泛型语法
// .tsx 文件中:
const f = <T>(v: T) => v; // ❌ 被解析为 JSX
const f = <T,>(v: T) => v; // ✅ 加逗号
const f = <T extends unknown>(v: T) => v; // ✅ 加约束(推荐)
坑点 5:以为泛型是运行时机制
// ⚠ T 只存在于编译期!运行时被完全擦除
function f<T>(v: T): T {
// console.log(T); // ❌ 运行时没有 T 这个东西
// if (v instanceof T) // ❌ 同样不行
return v;
}
// 想在运行时知道类型 → 传"类型守卫函数"或值本身(第 4 天的思路)
最佳实践清单
能用推断就不显式指定——显式仅用于返回位置泛型(request<T>)
命名:简单场景单字母,复杂签名全名
每个类型参数必须在签名中被使用
优先泛型而非重载(类型联动 vs 固定组合)
any 换泛型:项目里每见到一个返回 any 的工具函数,都是泛型的练手题
T 在函数体内保持克制——只能做全体类型通用的操作
十、自测挑战
Q1:Box<T> 和 Box<number> 的关系是什么?术语叫什么?
Q2:identity(65) 中 T 被推断为什么?identity("running" as const) 呢?
Q3:泛型和 any 的本质区别(两个关键词)?
Q4:什么情况下必须显式指定类型参数?举一个真实场景。
Q5:function f<T>(a: T, b: T) 调用 f(65, "hello") 会发生什么?为什么?
Q6:为什么重载版 process 的返回类型 number | string 不如泛型版 T 好用?
Q7:.tsx 中箭头函数泛型怎么写?为什么普通写法不行?
Q8:函数体内为什么不能 value.length(value: T)?想访问 length 怎么办(预告明天)?
Q9:手写 take<T>(arr: T[], n: number): T[](取前 n 个,n 越界返回全部)。
Q10:手写 zip<T, U>(a: T[], b: U[]): [T, U][](长度取短的),验证 [1,2,3] 和 ["a","b"] 的结果类型。
十一、总结与知识图谱
泛型基础(类型参数化)
│
├── 动机
│ ├── 类型的重复 → 类型参数(Box<T> 替代 N 个 Box)
│ └── 提取思想:变的抽出去(值→参数,类型→类型参数)
│
├── 语法
│ ├── function f<T>(v: T): T
│ ├── 箭头函数 <T>(.tsx 用 <T,> 或 extends)
│ ├── 泛型接口 interface Box<T> / 泛型类型别名 type Box<T>
│ └── 多参数 <T, K> / <T, R>
│
├── 推断
│ ├── 实参 → T(推荐,零手写)
│ ├── 字面量被拓宽(as const 保字面量)
│ └── 返回位置泛型必须显式(request<T>)
│
├── 本质
│ ├── any = 失忆(污染一切)
│ ├── 泛型 = 占位待填(类型流动)
│ └── 同一 T 全签名一致(信息不丢失)
│
├── 限制(明天的伏笔)
│ ├── T 默认未知类型:成员访问受限
│ └── 解法:extends 约束
│
└── 实战
├── request<T>(API 封装)
├── unique / chunk / countBy(数组工具)
├── debounce<F>(参数透传)
└── storage<T>(类型绑定存储)
一句话总结:函数参数化值,泛型参数化类型——<T> 是写在类型位置上的形参,调用时由实参自动填充,全程类型不丢失。
延伸阅读
下一步
本文是 TypeScript 深入 系列的第 8 天。明天进入泛型最重要的一环:
第 9 天:泛型约束 extends — 给类型参数立规矩(
T extends { length }、K extends keyof T黄金组合、getProperty 精确联动)
学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。
每天花 2 小时,28 天通关 TypeScript 深入。加油!