映射类型中 keyof 索引访问与 as 子句重映射键名的推导边界

在 TypeScript 里处理对象类型转换时,keyof、索引访问 T[K] 和 as 子句几乎总是一起出现。看起来只是三行泛型,实际写进项目里却经常出现两种极端:要么推导结果是 never,要么 as 把键名重映射成了 string 这种宽类型,导致下游类型守卫全部失效。本文围绕这三个语法点的交互边界展开,用可运行的代码片段说明什么时候能推导、什么时候会

在 TypeScript 里处理对象类型转换时,keyof、索引访问 T[K] 和 as 子句几乎总是一起出现。看起来只是三行泛型,实际写进项目里却经常出现两种极端:要么推导结果是 never,要么 as 把键名重映射成了 string 这种宽类型,导致下游类型守卫全部失效。本文围绕这三个语法点的交互边界展开,用可运行的代码片段说明什么时候能推导、什么时候会退化成宽类型、什么时候会得到空对象类型,以及怎样在受限条件下拿到确定的结果。

一、先把三个语法点的语义拆开

理解边界的前提是分清三者的作用域,它们并不在同一层。

  • keyof T:对类型 T 求键的联合类型。注意它对索引签名(index signature)返回的是 string 或 number,而不是字面量联合。
  • T[K]:索引访问类型,其中 K extends keyof T。它支持联合索引,T[A | B] 会得到 T[A] | T[B]。
  • as 子句(key remapping):在映射类型里改写生成的键。它右侧必须是可赋值给 string | number | symbol 的类型;返回 never 时该键会被剔除。

三者的执行顺序是:先对源类型做 keyof 得到键集合,再对每个键求值 T[K] 得到属性值,最后用 as 计算新键。关键点在于 as 的右侧表达式只能看到当前键 K 与源类型 T,看不到映射结果本身。这条限制决定了大量“想在里面再引用一次属性值”的写法会失败。

type Source = {
  id: number;
  name: string;
  createdAt: Date;
};

type Keys = keyof Source;          // "id" | "name" | "createdAt"
type IdType = Source["id"];        // number
type Mixed = Source["id" | "name"]; // number | string

上面这段没有任何悬念。真正开始出问题是从“在映射类型里组合使用”开始的。

二、as 子句的推导边界:模板字面量键与 never 剔除

as 最常见的用法是把键名加上前缀,比如生成 getter 类型。写法本身没问题,但模板字面量类型对 K 的约束是有边界的。

type Source = {
  id: number;
  name: string;
};

type Getters = {
  [K in keyof Source as `get${Capitalize<string & K>}`]: () => Source[K];
};

// 展开结果:
// {
//   getId: () => number;
//   getName: () => string;
// }
declare const g: Getters;
const n: number = g.getId();

这里 string & K 不是装饰。keyof Source 目前是字符串字面量联合,但如果 Source 带一个 symbol 键或者数字键,Capitalize<K> 会直接报错,因为 Capitalize 的形参约束是 string。用 string & K 把非字符串键过滤掉,是常见做法,但副作用是:被过滤掉的键会被剔除,而不是保留原名。这就是第一个容易踩的边界。

type WithSymbol = {
  id: number;
  [sym]: string;   // sym 是 unique symbol
};

declare const sym: unique symbol;

// 错误写法:Type 'K' does not satisfy the constraint 'string'.
// type Bad = { [K in keyof WithSymbol as `get${Capitalize<K>}`]: WithSymbol[K] };

type Ok = {
  [K in keyof WithSymbol as K extends string ? `get${Capitalize<K>}` : never]: WithSymbol[K];
};
// 结果只含 getId,symbol 键被剔除

对应的真实报错长这样:

Type 'K' does not satisfy the constraint 'string'.
  Type 'keyof WithSymbol' is not assignable to type 'string'.
    Type 'symbol' is not assignable to type 'string'.ts(2344)

第二条边界是 as ... : never 的剔除行为会静默改变类型的键集合。如果下游有 keyof Result 的约束,剔除之后约束会一起变化,编译期不会提醒你“我本来以为还有那个键”。在包装第三方类型时尤其危险。

经验规则:只要 as 右侧出现了条件类型,就把“键可能被剔除”当作契约的一部分写进注释或用一个断言测试锁住,不要依赖“它应该还在”。

第三条边界是模板字面量拼接产生交叉键碰撞。当两个不同的键经过转换后得到同一个字符串,结果不是报错,而是后一个覆盖前一个。

type Collide = {
  id: number;
  Id: string;
};

type Danger = {
  [K in keyof Collide as `get${Capitalize<string & K>}`]: Collide[K];
};
// "id" -> "getId", "Id" -> "getId"(Capitalize 后仍为 "Id"? 实际为 "Id")
// 若两者归一相同,后者覆盖前者,且不报错

这类问题在业务里通常出现在大小写不统一的后端字段上。检查手段是写类型断言:

type AssertEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2) ? true : false;
type _t = AssertEqual<keyof Danger, "getId">; // 期望 true

三、索引访问与 keyof 的退化:索引签名、可选属性与联合分布

很多人以为 keyof T 永远是字面量联合,实际上有三种常见退化。

1. 索引签名让 keyof 退化为 string 或 number

type Dict = { [k: string]: number };
type K = keyof Dict;      // string | number
type V = Dict[string];    // number

type NumKeyed = { [k: number]: boolean };
type K2 = keyof NumKeyed; // number

此时对 K 做 Capitalize 之类的字面量运算没有意义,因为 K 是 string | number 而不是具体字面量。as 用模板字面量拼接也只是把整个类型原样包一层,得到的是索引签名,不是有限键集合。想让映射类型产出有限键,必须先用 Exclude 或条件类型把键收窄。

2. 可选属性在索引访问里会自动带上 undefined

type Opt = { a?: string; b: number };
type A = Opt["a"];              // string | undefined
type B = Opt["a" | "b"];        // string | number | undefined
type All = Opt[keyof Opt];      // string | number | undefined

这条本身设计合理,但和 as 组合时会放大问题:如果映射类型的值用了 NonNullable<T[K]>,键还在、值的 undefined 被剥掉,可选性却不会自动消失,需要显式加 -?。

type RequiredValues<T> = {
  [K in keyof T]-?: NonNullable<T[K]>;
};
type R = RequiredValues<Opt>; // { a: string; b: number }

3. 联合类型上的 keyof 不分布

这是最容易误判的一条。keyof (A | B) 求的是公共键,而 keyof A | keyof B 才是所有键的并集。在泛型里写 keyof T 时,T 若是联合,结果会被收窄成公共键,映射类型随之丢字段。

type A = { id: number; name: string };
type B = { id: number; age: number };

type Intersect = keyof (A | B);       // "id"
type UnionKeys = keyof A | keyof B;   // "id" | "name" | "age"

// 映射类型里若 T extends A | B
type M<T> = { [K in keyof T]: T[K] };
declare const ab: A | B;
type MT = M<typeof ab>; // 只有 id 一个键

如果业务意图是“任一分支有的键都保留”,必须先分发再合并:

type UnionToRecord<T> = T extends unknown ? { [K in keyof T]: T[K] } : never;
type Distributed = UnionToRecord<A | B>;
// { id: number; name: string } | { id: number; age: number }

// 若想合并成一个对象类型(值取并集),用交叉后再展开
type Merged = UnionToRecord<A | B> extends infer U
  ? { [K in keyof U]: U[K] }
  : never;

注意上面 Merged 这一步其实拿不到所有键,因为 keyof U 仍然受联合约束。真正要合并需要借助键的并集:

type AllKeys<T> = T extends unknown ? keyof T : never;
type Merge<T> = {
  [K in AllKeys<T>]: T extends Record<K, infer V> ? V : never;
};
type M2 = Merge<A | B>;
// { id: number; name: string; age: number }

这段是本文的核心技巧之一:AllKeys 通过分配条件类型把并集键展开成真正的联合,再用 T extends Record<K, infer V> 逐键取到值。没有它,as 写什么都只能看到公共键。

四、as 右侧无法引用映射后的属性值

一个高频需求:把对象里值为函数类型的键挑出来,改成 onXxx。直觉写法会失败。

type Handler = () => void;
type Src = {
  save: Handler;
  name: string;
  remove: Handler;
};

// 错误直觉:想在 as 里判断值是不是函数
// type Bad = { [K in keyof Src as Src[K] extends Function ? `on${Capitalize<K>}` : never]: Src[K] };
// 实际上这条是合法的!因为 as 右侧可以访问 Src[K]

更正一下:as 的右侧可以访问 T[K],前面提到的“看不到映射结果”指的是不能引用“已经生成的键集合”,比如不能写 as keyof Result 这种自引用。所以上面的写法能工作:

type OnHandlers = {
  [K in keyof Src as Src[K] extends Function ? `on${Capitalize<string & K>}` : never]: Src[K];
};
// { onSave: Handler; onRemove: Handler }

真正受限的是下面这种:用映射生成的键去做反向查找。例如给每个键生成一个同名的校验函数,同时希望值类型引用新键。TypeScript 不允许 as 子句里的类型引用正在定义的这个映射类型本身,会报循环引用。

// 循环引用错误示例
// type Loop = {
//   [K in keyof Src as `check${Capitalize<string & K>}`]: (key: keyof Loop) => boolean;
// };
// 报错:Type alias 'Loop' circularly references itself.ts(2456)

规避方式是拆成两步泛型,用中间类型承接键集合:

type PrefixedKeys<T> = {
  [K in keyof T as `check${Capitalize<string & K>}`]: K;
};
type Checkers<T> = {
  [K in keyof PrefixedKeys<T>]: (key: PrefixedKeys<T>[K]) => boolean;
};
type C = Checkers<Src>;
// {
//   checkSave: (key: "save") => boolean;
//   checkName: (key: "name") => boolean;
//   checkRemove: (key: "remove") => boolean;
// }

这是把“键重映射”和“值引用键”解耦的标准手法,代价是多一层类型。类型层面没有运行时开销,但可读性会下降,建议只在公共工具类型里用。

五、映射类型不是同态的时候会丢修饰符

一个隐蔽边界:同态映射类型(homomorphic mapped type)会保留源类型的可选性与只读性,非同态不会。判断标准是键是否写成 [K in keyof T]。一旦 as 把键改成模板字面量,映射就不再是同态的,readonly 和 ? 会被剥离。

type ReadonlySrc = {
  readonly id: number;
  readonly name?: string;
};

// 同态:保留 readonly 与 ?
type Same = { [K in keyof ReadonlySrc]: ReadonlySrc[K] };
// { readonly id: number; readonly name?: string }

// 非同态(键被重映射):修饰符丢失
type Renamed = {
  [K in keyof ReadonlySrc as `x${Capitalize<string & K>}`]: ReadonlySrc[K];
};
// { xId: number; xName?: string }  —— readonly 没了

需要保留修饰符时,要手动加回去,并用 +readonly 与 -? 显式控制:

type RenamedKeep<T> = {
  +readonly [K in keyof T as `x${Capitalize<string & K>}`]-?: T[K];
};
type RK = RenamedKeep<ReadonlySrc>;
// { readonly xId: number; readonly xName: string }

顺带一个容易忽略的点:as 里返回 never 时属性被删除,-? 与 as never 效果不同,前者是“可选变必选”,后者是“键消失”。两者混用时务必在测试里断言键集合,否则重构后会静默改变 API 形状。

六、受限条件下的实用写法:限定键范围与型号推断

最后给两个在真实代码里反复用到的模式。

模式一:只对指定键做重映射

需求常常是“只重命名部分键,其余保持原名”,而不是全量重映射。用条件类型在 as 里分支即可,但要保证两个分支返回类型都能被 as 接受。

type RenameSome<T, K extends keyof T> = {
  [P in keyof T as P extends K ? `get${Capitalize<string & P>}` : P]: T[P];
};

type S = { id: number; name: string; age: number };
type R = RenameSome<S, "id" | "age">;
// { getId: number; name: string; getAge: number }

注意 K extends keyof T 约束能在调用侧拦截拼错的键,报错信息是 Type '"notExist"' does not satisfy the constraint '"id" | "name" | "age"',非常直观。

模式二:从值类型反推键,再重映射

当需要“找出所有函数键并重命名”,同时又要保证 Source[K] 的类型精度不丢,T[K] 必须放在映射的左侧值位置,不能先抽成联合再还原,否则 Function 判断会丢失具体签名。

type MethodKeys<T> = {
  [K in keyof T]-?: T[K] extends (...args: any[]) => any ? K : never;
}[keyof T];

type RenameMethods<T> = {
  [K in MethodKeys<T> as `on${Capitalize<string & K>}`]: T[K];
};

interface Service {
  load(): Promise<void>;
  save(id: number): void;
  version: string;
}

type Events = RenameMethods<Service>;
// { onLoad: () => Promise<void>; onSave: (id: number) => void }

这里 MethodKeys 用了“映射 + 索引访问”的经典组合:先对每个键产出 K 或 never,再用 [keyof T] 把值联合取出来,never 在联合中自动消失。它不是同态映射,但也不重命名键,修饰符问题在这里不构成影响。-? 保证可选方法也能被选中。

把两个模式合起来,就得到一个可复用的工具类型:

type EventMap<T, P extends keyof T = MethodKeys<T>> = {
  [K in P as `on${Capitalize<string & K>}`]: T[K];
};

type E = EventMap<Service>; // { onLoad: ...; onSave: ... }

泛型默认值让调用侧只写一个类型参数,同时保留用第二个参数收窄键的能力。这类写法的边界在于 P 必须能通过 keyof T 检查,如果 MethodKeys<T> 在某次推导中退化为 never(比如 T 是 any),EventMap 会变成一个空对象类型 {},而不是报错。想尽早发现,可以在工具类型上加一层保护:

type EventMapSafe<T> = [MethodKeys<T>] extends [never]
  ? never
  : EventMap<T>;

用元组包裹是为了避免条件类型在 never 上的分配行为直接把结果变成 never 而无法区分“没有方法”和“推导失败”这两种语义。这个细节在写库类型时很值得保留。

小结

把本文的边界整理成一份速查表:

场景 结果 处理方式
keyof 遇到索引签名 退化为 string | number 先用条件类型收窄键集合
keyof (A | B) 只保留公共键 用分配条件类型求键并集
T[K] 遇到可选属性 隐含 undefined 用 -? 与 NonNullable
as 返回 never 键被静默剔除 断言 keyof 结果锁住契约
as 重命名键 映射变非同态,丢 readonly/? 显式 +readonly / -?
as 引用映射自身 ts(2456) 循环引用 拆成两个泛型中间类型
模板字面量键碰撞 后者覆盖前者,不报错 类型断言检测键集合

一句话总结:keyof 负责取键但可能退化,索引访问负责取值但会带上 undefined,as 负责改名但会改变映射的同态性质。三者组合时务必显式声明每一次键集合的变化,并写出断言测试。

FAQ

Q1:为什么 { [K in keyof T as `get${K}`]: T[K] } 会报 ts(2322)?

模板字面量里 K 若含 symbol 或 number 类型,字符串插值位置会得到类型不匹配。把 K 改成 string & K,或者用条件类型 K extends string ? ... : never 过滤。前者静默剔除,后者同样剔除但语义更明确。

Q2:as never 剔除键之后,为什么下游的类型守卫没报错?

因为 TypeScript 只检查赋值兼容性,键被剔除后对象类型变小,原类型仍然可以赋给新类型(结构类型系统允许宽赋窄)。把下游函数的入参改成 { [K in keyof R]: ... } 这种依赖键集合的形态,才能让缺失键在编译期暴露。

Q3:映射类型里怎么判断某个键的值是数组?

用 T[K] extends readonly unknown[] ? ... : ...,注意写 readonly,否则 readonly string[] 不会被匹配。若想区分元组与普通数组,再加一层 number extends T[K]["length"] 判断。

Q4:为什么 MethodKeys<T> 在 T 为 any 时得到 never?

any 参与条件类型时结果会坍缩为两个分支的联合,keyof any 是 string | number | symbol,而 any[...] 仍是 any,any extends Function 两侧都成立,最终映射出的键是 never。用 EventMapSafe 那种元组包裹的检查可以提前拦截。

Q5:重映射后的键顺序会变吗?

键的联合类型本身无序,但对象类型的属性遍历顺序在类型打印与 IDE 提示中按声明顺序派生。重命名不改变顺序,as 分支中剔除的部分会导致序号前移。不要依赖类型层面的顺序做逻辑判断,只把它当展示细节。

Q6:+readonly 和 -readonly 能同时写在 as 子句映射上吗?

可以,修饰符位置在键与 as 之间:{ -readonly [K in keyof T as ...]: T[K] }。注意修饰符与 as 的顺序固定,写反会报 'as' expected 语法错误。