【TS】day08-generics-basics

作者:mario 发布时间: 2026-08-28 阅读量:1 评论数:0

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 本质区别

any

泛型 T

语义

“我不知道,你也别管”

“现在不知道,调用时确定,我全程记住”

传染性

污染接触的一切(返回 any,链式全 any)

类型精确流动(返回 T,链式全精确)

编译检查

关闭

全程开启

类比

失忆

占位待填

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

万能黑箱(贴上"随便装"标签)

什么都装得进,拿出来不知道是啥

重载

几种固定规格的箱子

规格外的组合没有箱子

泛型

快递面单(收件时填类型)

一个箱子,面单上写清内容物,全程可追溯

类型推断

面单自动识别内容物

连填都不用填(实参是什么就写什么)

显式指定

手动填面单

寄件内容无法自动判断时(如空箱 request<T>)

口诀: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 天的思路)

最佳实践清单

  1. 能用推断就不显式指定——显式仅用于返回位置泛型(request<T>)

  2. 命名:简单场景单字母,复杂签名全名

  3. 每个类型参数必须在签名中被使用

  4. 优先泛型而非重载(类型联动 vs 固定组合)

  5. any 换泛型:项目里每见到一个返回 any 的工具函数,都是泛型的练手题

  6. T 在函数体内保持克制——只能做全体类型通用的操作


十、自测挑战

Q1Box<T>Box<number> 的关系是什么?术语叫什么?

Q2identity(65) 中 T 被推断为什么?identity("running" as const) 呢?

Q3:泛型和 any 的本质区别(两个关键词)?

Q4:什么情况下必须显式指定类型参数?举一个真实场景。

Q5function 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> 是写在类型位置上的形参,调用时由实参自动填充,全程类型不丢失。


延伸阅读

资源

说明

TS Handbook - Generics

官方泛型文档(今天权威出处)

TypeScript Playground

验证所有推断结果


下一步

本文是 TypeScript 深入 系列的第 8 天。明天进入泛型最重要的一环:

  • 第 9 天:泛型约束 extends — 给类型参数立规矩(T extends { length }K extends keyof T 黄金组合、getProperty 精确联动)


学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。

每天花 2 小时,28 天通关 TypeScript 深入。加油!


评论