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 天版对比
// 联动的直观对比:
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 天函数反解的泛型版)
九、类比记忆:健身房会员制
十、常见坑点与最佳实践
坑点 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 {}
最佳实践清单
约束最小化:只写函数真正需要的成员(HasId 而非 Device)
键值锁定签名背下来:
<T, K extends keyof T>(obj: T, key: K, value: T[K])事件总线/映射表模式:
M extends Record<string, unknown>+K extends keyof M多重条件用交叉:
T extends A & B默认参数放最后,且优先给 E(错误类型)这类"次要参数"
索引签名对象慎用 keyof 泛型——键安全会失效
约束不代替守卫:类型管编译期,语义校验仍需运行时
十一、自测挑战
Q1:T extends { length: number } 中 extends 的准确含义?和类继承的区别?
Q2:为什么无约束的 T 连 .length 都不能访问?这是缺陷还是设计?
Q3:findById<T extends HasId> 为什么比 findById(无约束 + any 返回)好?说出两点。
Q4:逐步写出 getProperty(device, "status") 的五步推断过程(device 有 id/name/temp/status)。
Q5:setProperty(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 深入。加油!