在 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 语法错误。