【TS】day09-generic-constraints

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

TypeScript 泛型约束 extends — 给类型参数立规矩

昨天发现:T 是"未知类型",连 value.length 都不许访问——泛型的安全性以"能力丧失"为代价。今天学约束(Constraint):用 extends 给 T 划一个范围,换取成员访问权。其中 K extends keyof T 黄金组合是整个 TS 类型系统最著名的模式——事件总线、类型安全 getter/setter、字段更新器全都建立在它之上。


目录


一、为什么需要约束

1.1 昨天的困境

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

TS 的逻辑无懈可击:T 可能是任何类型,number 没有 length——不保证存在的东西不让用。

1.2 天平的两端

无约束的 T          ←———— 天平 ————→          any
能力:零(啥都访问不了)                    能力:无限(啥都能访问)
安全:满(绝对安全)                       安全:零(绝对危险)

约束(extends)= 把天平拉到中间:
  "只要你保证有 length,我就让你访问 length"
  能力:受控开放          安全:受控保留

约束的哲学:用"类型的承诺"换"操作的能力"——承诺得越多,能做的越多。

1.3 基本语法预览

// T extends 结构 —— T 必须"至少长得像"这个结构
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);   // ✅ 约束保证了 length 存在
}

二、extends 结构约束

2.1 第一个约束

/**
 * 约束:T 必须包含 length: number
 */
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}

logLength("hello");            // ✅ string 有 length
logLength([1, 2, 3]);          // ✅ 数组有 length
logLength({ length: 10 });     // ✅ 满足结构
logLength(65);                 // ❌ 编译错误:number 没有 length
logLength(true);               // ❌

读法T extends { length: number } = “T 必须能赋值给 { length: number }”= “T 至少有 length: number 这个形状”。

这是结构化类型(鸭子类型)在类型参数上的体现:不看名字看形状——有 length 就行,多出来的字段随意。

2.2 extends 不是"继承"

// ⚠ extends 在泛型约束里读作"满足结构",不是类的继承!
interface Device { id: string; temp: number }

function f<T extends Device>(v: T): void { /* ... */ }

// ✅ Device 本身
f(device);

// ✅ Device 的超集(多字段也行 —— 结构包含即可)
f({ id: "1", temp: 65, extra: true });

// ❌ 缺字段
f({ id: "1" });   // 缺 temp

// ✅ 子类型也行
class FaultDevice implements Device { id = "1"; temp = 90; }
f(new FaultDevice());

记忆:约束里的 extends = “是…的形状或更多”( assignable to)。

2.3 约束为联合类型

/**
 * 只接受数值或字符串(排除 null/undefined/对象)
 */
function format<T extends number | string>(value: T): string {
  return typeof value === "number" ? value.toFixed(2) : value.trim();
  // ⭐ 第 4 天守卫在泛型函数里照常工作:收窄 T 的联合
}

format(65);          // ✅
format("  hello ");  // ✅
format(null);        // ❌ 编译错误
format([1, 2]);      // ❌

2.4 多个类型参数、各自约束

/**
 * 键值对构建:K 必须是 string,V 任意
 */
function pair<K extends string, V>(key: K, value: V): Record<K, V> {
  return { [key]: value } as Record<K, V>;
}

const p = pair("temp", 65);    // Record<"temp", number>
p.temp;    // ✅ number —— 键类型精确到字面量!

三、约束的工业价值:HasId 模式

3.1 一次编写,全项目复用

工业系统里几乎所有实体都有 id。把这个共性抽成约束:

/** 所有可标识实体的最小契约 */
interface HasId {
  id: string;
}

/**
 * 按 id 查找 —— 约束 T 必须有 id
 */
function findById<T extends HasId>(list: T[], id: string): T | undefined {
  return list.find(item => item.id === id);
}

/**
 * 按 id 删除 —— 返回删除后的新数组
 */
function removeById<T extends HasId>(list: T[], id: string): T[] {
  return list.filter(item => item.id !== id);
}

/**
 * 按 id 更新 —— 泛型 + Partial 联手
 */
function updateById<T extends HasId>(
  list: T[],
  id: string,
  patch: Partial<T>
): T[] {
  return list.map(item => item.id === id ? { ...item, ...patch } : item);
}

3.2 同一函数服务所有实体

// Device 有 id ✅
const d = findById(devices, "CNC-001");        // Device | undefined
// AlertRecord 有 id ✅
const a = findById(alerts, "1");               // AlertRecord | undefined
// 用户、订单、消息…… 有 id 就能用

// ❌ 没有 id 的类型被拒之门外
findById([{ name: "x" }], "1");   // 编译错误:缺 id

// ⭐ 更妙的是:返回类型随实体变化 —— Device 进,Device 出
// 这就是"一次编写、处处精确"

3.3 约束可以是多个接口的组合

interface HasId { id: string }
interface HasTimestamp { createdAt: number }

/**
 * 双重约束:既有 id 又有时间戳(& 交叉类型在约束里)
 */
function sortByTime<T extends HasId & HasTimestamp>(list: T[]): T[] {
  return [...list].sort((a, b) => a.createdAt - b.createdAt);
}

// Device 有 id 但没有 createdAt → 被拒绝
// AlertRecord 有 id + time → 需要先适配字段名

四、keyof 约束:黄金组合详解

4.1 问题回顾

第 6 天写过非泛型版 getField

// 第 6 天版本:绑定死了 DeviceData
function getField(device: DeviceData, key: keyof DeviceData) {
  return device[key];
}

它只服务 DeviceData。换成泛型后,一个函数服务所有对象:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

4.2 逐层拆解(本周最重要的五步)

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]

调用:getProperty(device, "temp")   // device: { id: string; name: string; temp: number }

第 1 步:T 被推断
   实参 device → T = { id: string; name: string; temp: number }

第 2 步:keyof T 计算
   keyof T = "id" | "name" | "temp"

第 3 步:K 的约束生效
   K extends keyof T → K 只能是 "id" | "name" | "temp" 之一

第 4 步:K 被推断为字面量
   实参 "temp" → K = "temp"(不是宽泛的联合,是精确的字面量!)

第 5 步:T[K] 计算
   T["temp"] = number → 返回类型 number

关键洞察:第 4 步 K 被推断为单个字面量(而不是整个联合)——这是"键与返回类型联动"的机制核心。传什么键,就得什么类型。

4.3 各种调用的联动效果

const device = { id: "CNC-001", name: "机床", temp: 65, status: "running" as const };

const a = getProperty(device, "id");      // a: string
const b = getProperty(device, "name");    // b: string
const c = getProperty(device, "temp");    // c: number
const d = getProperty(device, "status");  // d: "running"(字面量保留!)

// 错误调用编译期拦截:
getProperty(device, "tmep");              // ❌ 拼错爆红
getProperty(device, "constructor");       // ❌ 不在键联合中
getProperty(device, 0);                   // ❌ number 不是键

4.4 泛型版 vs 第 6 天版对比

第 6 天版 getField

泛型版 getProperty

服务对象

只有 DeviceData

任何对象

返回类型

所有字段类型的联合(宽)

与键精确联动(窄)

复用性

每种类型写一份

一份通吃

// 联动的直观对比:
const x = getField(device, "temp");     // string | number | "running"(联合,要收窄)
const y = getProperty(device, "temp");  // number(直接用!)
y.toFixed(1);                           // ✅ 无需任何收窄

五、getProperty / setProperty:键值成对锁定

5.1 setProperty:双向安全

/**
 * 类型安全的属性设置(返回新对象,不可变风格)
 */
function setProperty<T, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]                  // ⭐ value 的类型被 key 锁定
): T {
  return { ...obj, [key]: value };
}

const device = { id: "CNC-001", name: "机床", temp: 65 };

const d1 = setProperty(device, "temp", 80);      // ✅ temp 要 number,传了 80
const d2 = setProperty(device, "name", "新机床");  // ✅
setProperty(device, "temp", "80");               // ❌ temp 要 number
setProperty(device, "nmee", 80);                 // ❌ 键拼错

签名的精妙value: T[K]——key 决定 value 的类型。键和值成对校验,传错任何一个都爆红。

5.2 键值锁定的意义(对比普通对象操作)

// 普通 update(对象展开):类型不校验字段合法性
const bad = { ...device, temp: "hot" };   // ✅ 编译通过!temp 类型被悄悄改掉
// 后续 bad.temp.toFixed(1) 才炸 —— 错误延迟暴露

// setProperty:错误当场拦截
setProperty(device, "temp", "hot");       // ❌ 立刻爆红

5.3 updateProperty:合并版(实战形态)

/**
 * 安全的单字段更新指令(BOSS 战 FieldUpdate 的泛型化)
 */
type FieldUpdate<T, K extends keyof T> = {
  field: K;
  value: T[K];
};

// 对 Device 使用
const u1: FieldUpdate<Device, "temp"> = { field: "temp", value: 80 };     // ✅
const u2: FieldUpdate<Device, "temp"> = { field: "temp", value: "80" };   // ❌
const u3: FieldUpdate<Device, "name"> = { field: "name", value: "x" };    // ✅

// 应用更新指令
function applyUpdate<T extends object>(
  obj: T,
  update: FieldUpdate<T, keyof T>
): T {
  return { ...obj, [update.field]: update.value };
}

六、约束 + 索引访问的进阶形态

6.1 泛型 pluck(第 6 天预告兑现)

/**
 * 从对象数组抽取一个字段(第 9 周 JS 手写版 → 现在类型完全体)
 */
function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
  return arr.map(item => item[key]);
}

const devices: Device[] = [
  { id: "CNC-001", name: "机床", temp: 65 },
  { id: "AGV-002", name: "搬运车", temp: 42 }
];

const names = pluck(devices, "name");   // string[] ⭐ 精确
const temps = pluck(devices, "temp");   // number[] ⭐ 精确
pluck(devices, "nmee");                 // ❌ 爆红

6.2 泛型 groupBy

/**
 * 按字段分组(K 锁定合法字段,返回 Map)
 */
function groupBy<T, K extends keyof T>(
  arr: T[],
  key: K
): Map<T[K], T[]> {
  const map = new Map<T[K], T[]>();
  for (const item of arr) {
    const k = item[key];
    const list = map.get(k);
    if (list) list.push(item);
    else map.set(k, [item]);
  }
  return map;
}

const byStatus = groupBy(devices, "status");
// Map<DeviceStatus, Device[]> —— 分组键与值类型全部精确
byStatus.get("running")?.[0].temp;   // ✅

6.3 约束链:数值字段求和

/**
 * 只允许对 number 字段求和(约束 + 条件类型的组合,Day 12 详解)
 */
type NumericKeys<T> = {
  [K in keyof T]: T[K] extends number ? K : never;
}[keyof T];

function sumField<T, K extends NumericKeys<T>>(arr: T[], key: K): number {
  return arr.reduce((s, item) => s + (item[key] as number), 0);
}

interface Device {
  id: string;
  name: string;
  temp: number;
  battery: number;
}

sumField(devices, "temp");      // ✅ temp 是 number
sumField(devices, "battery");   // ✅
sumField(devices, "name");      // ❌ name 是 string,被 NumericKeys 过滤掉了

今天只需记住效果:约束可以按"字段类型特征"筛选合法键。原理(条件类型 + never)Day 12 拆解。


七、默认类型参数与约束组合

7.1 默认类型参数

/**
 * 默认值:不传 T 时用 string
 */
function createList<T = string>(items?: T[]): T[] {
  return items ?? [];
}

const a = createList();            // string[](默认)
const b = createList([1, 2]);      // number[](推断覆盖默认)

// 常见组合:Result<T, E = Error>
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

const r1: Result<number> = { ok: true, value: 65 };        // E 用默认 Error
const r2: Result<number, string> = { ok: false, error: "超时" };  // 自定义

7.2 约束 + 默认值的经典组合

/**
 * 事件总线映射表的默认约束(明天泛型类的预演)
 */
type EventMap = Record<string, unknown>;

function createBus<M extends EventMap = Record<string, unknown>>(): void {
  // M 至少是"字符串键对象",不传时用最宽默认
}

7.3 约束的顺序规则

// ⚠ 有默认值的类型参数必须在后面(同函数默认参数)
function f<T, U = T>(a: T, b: U): void {}       // ✅ 默认参数可用前面的 T
function g<T = string, U extends T>(v: U): void {}  // ⚠ U 依赖默认的 T,合法但绕

// 推荐顺序:必选参数在前,带约束的在前,带默认值的在后
function h<K extends string, V = unknown>(key: K, value: V): void {}

八、实战场景全覆盖

8.1 场景一:泛型事件总线(BOSS 战的核心模式泛化)

/**
 * 通用事件总线:任何"事件名 → 载荷"映射表都能用
 */
class EventBus<M extends Record<string, unknown>> {
  private handlers: {
    [K in keyof M]?: ((payload: M[K]) => void)[];
  } = {};

  /** 订阅:事件名和回调参数类型联动 */
  on<K extends keyof M>(event: K, fn: (payload: M[K]) => void): void {
    (this.handlers[event] ??= []).push(fn);
  }

  /** 触发:载荷类型与事件名锁定 */
  emit<K extends keyof M>(event: K, payload: M[K]): void {
    this.handlers[event]?.forEach(fn => fn(payload));
  }

  /** 退订 */
  off<K extends keyof M>(event: K, fn: (payload: M[K]) => void): void {
    const list = this.handlers[event];
    if (!list) return;
    const i = list.indexOf(fn);
    if (i >= 0) list.splice(i, 1);
  }
}

// ===== 使用:一张映射表 = 一套类型安全的事件系统 =====
type HubEvents = {
  "hub:alert": AlertRecord;
  "hub:stats": DeviceStats;
  "hub:clear": { deviceId: string };
};

const hub = new EventBus<HubEvents>();

hub.on("hub:alert", a => console.log(a.reason));   // ✅ a: AlertRecord 自动
hub.emit("hub:stats", { total: 3 });               // ✅
hub.emit("hub:alerts", {});                        // ❌ 事件名拼错爆红

// 换个映射表:UI 事件系统 2 行搞定
type UiEvents = { "modal:open": string; "toast:show": string };
const uiBus = new EventBus<UiEvents>();

这个模式的本质K extends keyof M事件名(键)与载荷(值)永远成对出现——第 5 天"Record 完整性" + 第 6 天"keyof" + 今天"泛型约束"三合一。

8.2 场景二:类型安全的表单验证器

/**
 * 每个字段的校验器接收【对应字段类型】的值
 */
function validate<T extends object>(
  obj: T,
  rules: { [K in keyof T]?: (value: T[K]) => string | null }
): string[] {
  const errors: string[] = [];
  for (const key in rules) {
    const rule = rules[key];
    if (rule) {
      const msg = rule(obj[key]);
      if (msg) errors.push(msg);
    }
  }
  return errors;
}

// ===== 使用 =====
const device = { id: "CNC-001", temp: 95, name: "机床" };

const errors = validate(device, {
  // value 自动是 string(id 字段的类型)✅
  id: v => v.startsWith("CNC-") ? null : "编号必须以 CNC- 开头",
  // value 自动是 number(temp 字段的类型)✅
  temp: v => v >= 20 && v <= 120 ? null : "温度超出合理范围",
  // value 自动是 string ✅
  name: v => v.trim() ? null : "名称不能为空"
});
// rules 的键被锁定为 T 的字段;每个回调的参数类型自动匹配对应字段

8.3 场景三:泛型缓存(约束 + Map)

/**
 * 带 TTL 的缓存:任何键值类型,键必须是 string
 */
class Cache<V> {
  private store = new Map<string, { value: V; expire: number }>();

  set(key: string, value: V, ttlMs = 5000): void {
    this.store.set(key, { value, expire: Date.now() + ttlMs });
  }

  get(key: string): V | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (Date.now() > entry.expire) {
      this.store.delete(key);      // 过期清理
      return undefined;
    }
    return entry.value;
  }
}

// 设备数据缓存(5 秒 TTL,配合轮询)
const deviceCache = new Cache<Device[]>();
deviceCache.set("devices", devices, 3000);
const cached = deviceCache.get("devices");   // Device[] | undefined

8.4 场景四:防抖的完全体(昨日伏笔兑现)

/**
 * 防抖:参数类型完全透传(Parameters 是泛型工具,Day 11 详解)
 */
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");   // ✅ 与 search 参数完全一致
debounced(123);            // ❌ 爆红
// F extends (...args) => any:F 被约束为"函数类型"
// Parameters<F>:从 F 反解参数元组(第 6 天函数反解的泛型版)

九、类比记忆:健身房会员制

概念

类比

说明

无约束 T

只许看,不许碰器械

绝对安全,零能力

T extends { length }

有基础体测者可用跑步机

一个承诺换一类器械

T extends HasId

会员卡制度

一张卡(一个约束)通全馆

T extends A & B

VIP 多卡合一

满足多重条件开更多区域

K extends keyof T

储物柜钥匙牌

每把钥匙(K)对应特定柜格(T[K]),插错钥匙开不了门

value: T[K]

柜格里的东西与钥匙牌对应

键值成对校验

默认类型参数

散客默认方案

不说明就按基本配置来

约束链(NumericKeys)

器械分区准入(只许练腿的进腿区)

按类型特征筛选


十、常见坑点与最佳实践

坑点 1:约束过窄,复用性归零

// ❌ 约束到具体类型 —— 等于没泛型
function findById<T extends Device>(list: T[]): T | undefined { /* ... */ }
// 只能查 Device 及其子类型,AlertRecord 用不了

// ✅ 约束到最小契约
function findById<T extends HasId>(list: T[]): T | undefined { /* ... */ }

原则:约束应该表达"这个函数真正需要的最低要求",而不是"我碰巧要传的数据类型"。

坑点 2:keyof 约束遇上索引签名

interface Loose { [k: string]: number }

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }

const loose: Loose = { a: 1 };
getProperty(loose, "anything");   // ✅ 竟然通过!
// keyof Loose = string —— 任意字符串都是"合法键",类型安全失效

// ⚠ 索引签名对象的 keyof 是宽的(第 6 天知识的新后果)
// 需要精确键安全 → 用精确 interface / as const 对象

坑点 3:可选属性的 keyof 照样包含该键

interface Config { url: string; token?: string }

function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }

const cfg: Config = { url: "ws://x" };
get(cfg, "token");   // 返回 string | undefined(可选字段类型含 undefined)
// ⚠ 不要以为 keyof 排除了可选键 —— 键存在,值可能 undefined

坑点 4:约束和实参的"形状欺骗"

function logLength<T extends { length: number }>(v: T): number {
  return v.length;
}

logLength({ length: 0 });   // ✅ 合法 —— 但这可能是个错误数据
// 结构化约束只保证"有这个形状",不保证"语义正确"
// 需要语义校验 → 运行时守卫(类型与守卫各管一层,D4 心法)

坑点 5:默认类型参数放在前面

// ❌ 编译错误:必选参数不能跟在默认参数后面
function f<T = string, U>(v: U): void {}

// ✅ 默认在后
function f<U, T = string>(v: U): void {}

最佳实践清单

  1. 约束最小化:只写函数真正需要的成员(HasId 而非 Device)

  2. 键值锁定签名背下来<T, K extends keyof T>(obj: T, key: K, value: T[K])

  3. 事件总线/映射表模式M extends Record<string, unknown> + K extends keyof M

  4. 多重条件用交叉T extends A & B

  5. 默认参数放最后,且优先给 E(错误类型)这类"次要参数"

  6. 索引签名对象慎用 keyof 泛型——键安全会失效

  7. 约束不代替守卫:类型管编译期,语义校验仍需运行时


十一、自测挑战

Q1T extends { length: number } 中 extends 的准确含义?和类继承的区别?

Q2:为什么无约束的 T 连 .length 都不能访问?这是缺陷还是设计?

Q3findById<T extends HasId> 为什么比 findById(无约束 + any 返回)好?说出两点。

Q4:逐步写出 getProperty(device, "status") 的五步推断过程(device 有 id/name/temp/status)。

Q5setProperty(obj, key, value) 的签名里,value: T[K] 起到什么作用?

Q6:索引签名对象 { [k: string]: number } 传给 getProperty 会发生什么?为什么?

Q7:可选字段 token?: string 出现在 keyof 里吗?getProperty(cfg, "token") 的返回类型?

Q8:手写 hasKey<T, K extends keyof T>(obj: T, key: K): boolean

Q9:手写 omitKey<T extends object, K extends keyof T>(obj: T, key: K): Omit<T, K>(用展开 + 解构实现)。

Q10:事件总线 on<K extends keyof M>(event: K, fn: (payload: M[K]) => void) —— 为什么回调参数类型能随事件名变化?


十二、总结与知识图谱

泛型约束(extends)
│
├── 动机
│   └── T 未知类型零能力 → 用承诺换能力
│
├── 结构约束
│   ├── T extends { length: number }(成员访问权)
│   ├── T extends 联合(限定类型族)
│   ├── T extends A & B(多重条件)
│   └── 语义:满足结构即可(不是类继承)
│
├── HasId 模式(工程价值巅峰)
│   ├── findById / removeById / updateById
│   └── 一次编写,全实体复用
│
├── keyof 黄金组合
│   ├── getProperty<T, K extends keyof T>(obj, key): T[K]
│   ├── setProperty(value: T[K] 键值成对锁定)
│   ├── pluck / groupBy(数组层应用)
│   └── 机制核心:K 推断为字面量 → T[K] 精确联动
│
├── 进阶形态
│   ├── 约束链(NumericKeys 按类型特征筛键 → Day 12)
│   └── 默认类型参数 <T, E = Error>(放最后)
│
└── 实战
    ├── 泛型事件总线(K extends keyof M ⭐)
    ├── 表单验证器(字段级类型匹配)
    ├── Cache<V>(TTL 缓存)
    └── debounce<F>(参数透传)

一句话总结:约束是用"类型的承诺"换"操作的能力"——K extends keyof T 是最有价值的承诺:让键、值、返回类型三者永远联动。


延伸阅读


下一步

本文是 TypeScript 深入 系列的第 9 天。接下来:

  • 第 10 天:泛型接口与泛型类 — Page<T>、泛型分页器、Repository 模式(约束的框架级应用)


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

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


评论