用泛型约束 extends keyof 实现代理对象属性读取的编译期校验

做前端状态管理或 SDK 封装时,经常要写一层代理对象:它包住一份内部数据,外部只能通过 get('userName') 这类方法读取字段。麻烦在于,字符串键名没有任何类型信息——拼错一个字母、改字段名忘了同步、访问一个不存在的属性,TypeScript 全都放行,等到运行时才返回 undefined,排查链路直接拉到最长。本文解决的就是这件事:利用 K e

做前端状态管理或 SDK 封装时,经常要写一层代理对象:它包住一份内部数据,外部只能通过 get('userName') 这类方法读取字段。麻烦在于,字符串键名没有任何类型信息——拼错一个字母、改字段名忘了同步、访问一个不存在的属性,TypeScript 全都放行,等到运行时才返回 undefined,排查链路直接拉到最长。本文解决的就是这件事:利用 K extends keyof T 把「属性名必须是目标类型真实存在的键」这一约束下沉到编译期,让错误在编辑器里就红掉。

一、问题的起点:字符串键名为什么是类型黑洞

先看一段最常见的写法,ProxyStore 负责包住任意对象,通过 get 取值:

interface User {
  id: number;
  userName: string;
  email: string;
  createdAt: Date;
}

class ProxyStore {
  constructor(private data: Record<string, unknown>) {}

  get(key: string): unknown {
    return this.data[key];
  }
}

const store = new ProxyStore({
  id: 1,
  userName: 'fwdd',
  email: 'dev@fwdd.net',
  createdAt: new Date(),
});

const name = store.get('usrName'); // 拼错了,编译器毫无反应
console.log(name); // undefined

这段代码能编译通过,但运行结果是 undefined。问题的根源在于 key: string 把键名信息完全丢掉了:

  • 键名写错(usrName)无法发现;
  • 接口字段重命名后,散落各处的调用点不会报错;
  • 返回类型只能是 unknown,取出来还得手写断言。

有人会用枚举兜底,但枚举需要额外维护,且和接口字段是两份定义,天然会漂移。更直接的思路:让 get 的键名参数直接受目标对象类型约束。

二、extends keyof 的三段式写法

核心只需要三行泛型。先定义存储类,把整体数据类型作为类级泛型参数 T:

class TypedStore<T extends object> {
  constructor(private data: T) {}

  get<K extends keyof T>(key: K): T[K] {
    return this.data[key];
  }
}

const store = new TypedStore({
  id: 1,
  userName: 'fwdd',
  email: 'dev@fwdd.net',
  createdAt: new Date(),
});

const name = store.get('userName');   // string
const id = store.get('id');           // number
store.get('usrName');                 // 编译错误

三个关键点逐条拆开:

  • T extends object:限定 T 必须是对象类型,否则 keyof T 对 string、number 之类的原始类型会退化成一堆内置方法名(如 toString、valueOf),约束形同虚设。
  • K extends keyof T:keyof T 求出 T 全部键名组成的联合类型,此处即 'id' | 'userName' | 'email' | 'createdAt'。参数类型用 K 而非直接写 keyof T,是为了让编译器把字面量类型「保留」下来供下一步推导。
  • T[K]:索引访问类型,返回类型随键名自动精确化,取 'id' 得 number,取 'createdAt' 得 Date,彻底告别 unknown 和手动断言。

把错误的键名传进去,编译器给出的报错非常明确:

// 报错原文:
// Argument of type '"usrName"' is not assignable to parameter of type
// '"id" | "userName" | "email" | "createdAt"'.
store.get('usrName');

注意报错里直接列出了合法取值,这比「undefined 无法读取属性」之类的运行时错误好排查得多。编辑器里输入 ' 之后的自动补全也会把这四个键名全部列出来。

这里有个容易踩的坑:如果写成 get(key: keyof T): T[keyof T],参数是联合类型没问题,但返回值会退化成所有属性类型的联合(number | string | Date),类型精度丢失。务必用「参数位置单独引入 K」的写法。

三、键名类型保留的关键:字面量推导与 K 的传递

很多人第一次尝试时会写成这样,然后发现约束不生效:

function getValue<T extends object>(obj: T, key: keyof T) {
  return obj[key]; // 返回类型是 T[keyof T],联合类型
}

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

const user: User = { id: 1, userName: 'fwdd', email: 'dev@fwdd.net', createdAt: new Date() };

const a = getValue(user, 'id');       // number | string | Date
const b = getValueRight(user, 'id');  // number

差异来自 TypeScript 的泛型推导机制:调用 getValueRight(user, 'id') 时,编译器会为 K 推断出字面量类型 'id'(而不是宽化成 string),于是 T[K] 被解析为 T['id'],即 number。而前一个版本没有 K 这个占位,返回类型只能是 T[keyof T],所有可能类型的并集。

同样的原理适用于方法。这里再强调一个类方法里的细节:类级泛型 T 在方法内部可直接使用,但方法自己的泛型参数必须写在方法名后的尖括号里,不能混到类上,否则一个实例就只能服务一种键名:

class TypedStore<T extends object> {
  constructor(private data: T) {}

  // 正确:K 属于方法级泛型
  get<K extends keyof T>(key: K): T[K] {
    return this.data[key];
  }

  // 错误示范:把 K 提到类上,实例创建时就固定了键名
  // class BadStore<T extends object, K extends keyof T> { ... }
}

四、实战:可读写的代理 Store,带上 set 与 has

光有 get 不够,实际 SDK 一般需要读写和存在性判断。把约束扩展到 set 上时,要点是值的类型必须与键名联动:

class TypedStore<T extends object> {
  private data: T;

  constructor(initial: T) {
    this.data = { ...initial };
  }

  get<K extends keyof T>(key: K): T[K] {
    return this.data[key];
  }

  set<K extends keyof T>(key: K, value: T[K]): void {
    this.data[key] = value;
  }

  has<K extends keyof T>(key: K): boolean {
    return key in this.data;
  }

  keys(): Array<keyof T> {
    return Object.keys(this.data) as Array<keyof T>;
  }

  snapshot(): Readonly<T> {
    return { ...this.data };
  }
}

const store = new TypedStore({
  id: 1,
  userName: 'fwdd',
  email: 'dev@fwdd.net',
  createdAt: new Date(),
});

store.set('userName', '范文多多');  // OK
store.set('id', 'abc');            // 报错:string 不能赋给 number
store.set('age', 18);              // 报错:age 不在 keyof T 中

注意 keys() 里的类型断言 as Array<keyof T>。这是必要的妥协:Object.keys 的官方签名固定返回 string[],TypeScript 不会替你推断成 keyof T。断言在运行时是安全的(Object.keys 确实只返回自有可枚举键),但如果你传入的对象是接口实例并且带有可选属性,就要意识到 keys() 可能不包含未定义的键。Object.keys 的缺失项意味着不要拿它做穷举校验。

set 的第二个参数写成 value: T[K],效果是类型随键联动:set('id', 1) 只接受 number,set('createdAt', new Date()) 只接受 Date。传错会看到:

// 报错原文:
// Argument of type 'string' is not assignable to parameter of type 'number'.
store.set('id', 'abc');

五、把约束用在真实场景:字段级权限代理

再往前一步。extends keyof 不仅能校验键名存在,还能和映射类型组合,做出「只允许读取白名单字段」的代理。假设后台返回的用户对象含敏感字段,前端代理只暴露非敏感部分:

type Sensitive = 'passwordHash' | 'internalNote';

type PublicKeys<T> = Exclude<keyof T, Sensitive>;

class SafeProxy<T extends object> {
  constructor(private data: T) {}

  read<K extends PublicKeys<T>>(key: K): T[K] {
    return this.data[key];
  }
}

interface Account {
  id: number;
  userName: string;
  passwordHash: string;
  internalNote: string;
}

const account: Account = {
  id: 7,
  userName: 'fwdd',
  passwordHash: 'sha256:aaaa',
  internalNote: 'do not leak',
};

const safe = new SafeProxy(account);
safe.read('userName');      // OK,返回 string
safe.read('passwordHash');  // 报错:合法键名里不含 'passwordHash'

报错信息会明确告诉你不在允许集合内:

// 报错原文:
// Argument of type '"passwordHash"' is not assignable to parameter of type
// '"id" | "userName" | "internalNote"'.
//   Type '"passwordHash"' is not assignable to type
//   '"id" | "userName" | "internalNote"'.
safe.read('passwordHash');

这里 Exclude<keyof T, Sensitive> 的作用是从键名联合里剔除敏感字段。注意 Exclude 对 keyof T 来说是逐成员求差集,所以结果仍是字面量联合,能被 K extends 正常约束。Omit 也能达到类似效果,但 Omit 作用于类型整体、适合构造新类型,Exclude 作用于联合、更适合在参数约束位置直接用。

一个容易翻车的变体:泛型参数被宽化

如果 SafeProxy 的构造参数没有用泛型收窄,而是直接标注为 Account,那么类型推导只能依赖 T 的显式声明。反过来,如果调用处传的是对象字面量而非变量,推导依然成立;但若传的是 Record<string, unknown>,keyof T 就退化成 string,约束失效。以下写法就会悄悄失去保护:

const raw: Record<string, unknown> = { ...account };
const weak = new SafeProxy(raw);
weak.read('anything');  // 编译通过,因为 keyof Record<string, unknown> 是 string

解决方式是给调用方补一个显式类型参数 new SafeProxy<Account>(account),或者干脆让构造参数类型保持 T 不动、不要在中间变量上做宽化标注。

六、配合 noUncheckedIndexedAccess 与运行期兜底

编译期校验挡住的是「键名不存在」和「类型不匹配」,但挡不住 undefined。如果目标对象声明了可选属性,或者索引签名允许缺失值,T[K] 仍可能是 undefined。在 tsconfig.json 中开启:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitAny": true,
    "exactOptionalPropertyTypes": true
  }
}

开启后,对索引签名类型(如 Record<string, T>)的访问结果会自动加上 | undefined,强迫你处理缺失分支。对本文的 TypedStore 而言,因为 data 是精确的接口类型、键名都要求必选(除非原接口本身可选),所以 get 的返回值不会平白多出 undefined;但如果你把 T 声明为 Partial<User>,返回值就会变成 string | undefined,这正是期望行为。

运行期仍需一层兜底,因为类型系统管不到外部注入的数据:

class TypedStore<T extends object> {
  constructor(private data: T) {}

  get<K extends keyof T>(key: K): T[K] {
    if (!(key in this.data)) {
      throw new Error(`[TypedStore] missing key: ${String(key)}`);
    }
    return this.data[key];
  }
}

const s = new TypedStore<Partial<User>>({ id: 1 });
try {
  s.get('userName');
} catch (err) {
  // 运行期报错文本:[TypedStore] missing key: userName
  console.error((err as Error).message);
}

注意 String(key) 对 keyof T 这种可能含 symbol 的联合是有必要的:模板字符串直接拼接 symbol 会抛 TypeError: Cannot convert a Symbol value to a string。这也是 keyof 相关代码里最常见的运行时坑之一。

小结与 FAQ

K extends keyof T 的价值在于用类型系统替代人肉记忆:键名集合只在接口定义处维护一份,get / set / has 全部自动同步;T[K] 让返回类型精确到字段级别;再叠加 Exclude 就能做字段级白名单。三个写法要点记住:类级泛型放 T,方法级泛型放 K,参数位置用 K 而不是 keyof T。

FAQ

Q:为什么 K extends keyof T 不写成 K: keyof T?
两者都能约束键名合法性,区别在返回类型:K 作为独立类型参数保留了字面量信息,配合 T[K] 能得到精确类型;直接写 keyof T 则返回 T[keyof T] 的并集。写 extends 是唯一能同时拿到「约束」和「精确返回值」的形式。

Q:传进去的键名是变量而不是字面量,怎么办?
变量的类型若是 string,必然不满足 K extends keyof T,会直接报「Argument of type ‘string’ is not assignable to parameter of type …」。此时应为变量标注具体字面量联合,例如 const key: keyof User = 'id',或使用 as const 的常量对象。

Q:把对象传给函数后,keyof T 退化成了 string 是怎么回事?
说明 T 被推导成了带字符串索引签名的类型(如 Record<string, unknown>),keyof 自然是 string | number。检查中间变量是否做了宽化标注,或在调用处显式写 fn<User>(user)。

Q:Object.keys 返回 string[],强制断言成 Array<keyof T> 安全吗?
运行时安全(它只会返回自有可枚举字符串键),但类型层面需要你知道可选属性不会出现在结果中。若需要严格的键名穷举,改用显式的键名常量数组并加 satisfies readonly (keyof T)[] 校验。

Q:报错 Type 'symbol' cannot be used as an index type 怎么处理?
出现这种错误通常是把值当成了键。若 keyof T 含 symbol(对象上定义了 symbol 键),索引访问本身是允许的,但要避免把它拼进模板字符串或传给 JSON.stringify,用 String(key) 显式转换并视情况跳过 symbol 分支。

Q:这种写法对性能有影响吗?
泛型约束、索引访问类型、Exclude 全部在编译期擦除,产物 JavaScript 与你手写的普通代理一致,运行时零开销。唯一成本是类型检查时间,泛型嵌套过深的超大联合可能会让 tsc 变慢,必要时用 // @ts-expect-error 精确定位单点问题而不是关闭整体检查。