Yuzhe's Blog

yuzhes

Append to Object

Append to Object

题目链接

题目

实现一个向接口添加新字段的类型。该类型接受三个参数,输出结果应为包含新字段的对象类型。

type Test = { id: '1' }
type Result = AppendToObject<Test, 'value', 4> // 期望结果:{ id: '1', value: 4 }

解答

type AppendToObject<T, U extends string, V> = {
  [K in keyof T | U]: K extends keyof T ? T[K] : V
}

核心思路:遍历已有键与新键的联合,再用条件类型决定每个属性的值类型。

逐步拆解

第一步 — 合并键集:keyof T | U

keyof T 取出 T 的所有已有键,U 是要新增的键,两者取联合,映射类型就能覆盖所有属性——原有的和新增的都不会漏掉。

第二步 — 决定每个键的值类型

K extends keyof T ? T[K] : V

结果: 一个扁平的对象类型,包含所有原有属性,加上新的 U: V 条目。

为什么不直接用 T & { [P in U]: V }

交叉类型的写法也能工作,但结果是交叉类型而非扁平对象:

type AppendToObject<T, U extends string, V> = T & { [P in U]: V }
// 结果类型:{ id: '1' } & { value: 4 }

TypeScript 在结构上会把它们视为等价,但在 IDE 的类型提示里显示的是交叉类型,而不是一个干净的合并对象。映射类型方案产生的是单一、扁平的对象类型,更直观、更易读。

深入理解

约束 U extends string 的意义

TypeScript 中对象的键可以是 stringnumbersymbol。这里用 U extends string 约束新键必须是字符串字面量类型。没有这个约束,TypeScript 会报错,因为 keyof T | U 要求合法的键类型。

实际上,这个约束还能让 TypeScript 把 U 收窄到具体的字符串字面量(例如 'value'),这才能让 AppendToObject<Test, 'value', 4> 解析为 { id: '1'; value: 4 },而不是 { id: '1'; [x: string]: 4 }

如果新键已存在会怎样?

如果 U 已经是 T 的键,条件 K extends keyof T ? T[K] : V 永远走第一个分支,保留原始类型——新值 V 不会覆盖旧值。如果想让新值优先(类似 Merge 的语义),调换条件顺序即可:

type AppendOrOverwrite<T, U extends string, V> = {
  [K in keyof T | U]: K extends U ? V : K extends keyof T ? T[K] : never
}

Merge 的关系

AppendToObject 本质上就是一个特殊化的 Merge,第二个”类型”只是一个只有一个属性的对象 { [U]: V }

type AppendToObject<T, U extends string, V> = Merge<T, { [P in U]: V }>

先理解了 MergeAppendToObject 就变得极其简单——本质是同一个模式。

同态映射类型 vs 非同态映射类型

这里的映射类型是非同态的:它不是直接对 keyof T 映射,所以 T 上的修饰符如 readonly? 不会自动保留。

type Source = { readonly id: string; name?: string }
type Result = AppendToObject<Source, 'age', number>
// { id: string; name: string | undefined; age: number }
// ^ readonly 和 ? 都丢失了

如果需要保留修饰符,可以把映射类型拆成两部分取交叉:

type AppendToObject<T, U extends string, V> =
  { [K in keyof T]: T[K] } & { [P in U]: V }

对于大多数使用场景(包括这道题),简单版本已经足够。

核心要点