类型层面的深度合并:Defu 如何在 TypeScript 中建模递归对象合并
前置知识
- ›第 1-2 篇:架构与递归合并算法
- ›TypeScript 中级水平:泛型、条件类型、infer 关键字
- ›熟悉映射类型与递归类型别名
- ›理解 TypeScript 的类型层面编程模式
类型层面的深度合并:Defu 如何在 TypeScript 中建模递归对象合并
在第 2 篇中,我们梳理了 _defu 的每一条运行时分支:跳过空值、拼接数组、递归处理普通对象、覆盖其他所有情况。现在,想象一下把这套逻辑再实现一遍——不是在运行时,而是在类型系统中。这正是 src/types.ts 所做的事情。这个文件共 112 行,是整个项目中最长的文件,它用递归条件类型完整地镜像了运行时算法,使得 defu({ a: 1 }, { b: 'hello' }) 不仅能返回正确的值,还能返回正确的类型:{ a: number; b: string }。
本文面向希望深入理解如何构建类型系统、使其忠实地建模复杂运行时行为的开发者。我们将逐一拆解整个文件中的每一个类型。
概述:为什么要在类型层面镜像运行时逻辑?
Defu 不仅用于业务代码,还被广泛用于框架配置。当 Nuxt 调用 defu(userConfig, frameworkDefaults) 时,合并后的对象会流入数十个下游系统,而这些系统都依赖特定的类型。如果合并后的类型只是 any 或一个宽泛的交叉类型,自动补全就会失效,类型错误无从捕获,而那些本该由 TypeScript 发现的配置问题只能靠人工排查。
目标是实现一一对应:_defu 中的每一个运行时决策,在类型层面都有对应的实现。当运行时跳过 null 源值并保留默认值时,类型系统应推断出默认值的类型;当运行时拼接数组时,类型应产生 Array<SourceElement | DefaultElement>;当运行时拒绝对 Date 或 RegExp 进行深度合并时,类型应产生联合类型而非合并后的对象。
下面来看整个类型文件的结构组织:
flowchart TD
Input["Input, IgnoredInput<br/>Merger, nullish<br/>(foundation types)"] --> MO["MergeObjects<D, Def><br/>(key-by-key mapped type)"]
Input --> MA["MergeArrays<D, S><br/>(array concat type)"]
MO --> M["Merge<D, Def><br/>(dispatch chain)"]
MA --> M
M --> MO
MO --> Defu["Defu<S, D><br/>(variadic tuple recursion)"]
Defu --> DefuFn["DefuFn<br/>(function signature)"]
DefuFn --> DefuInstance["DefuInstance<br/>(interface with fn, arrayFn, extend)"]
辅助类型与守卫类型
文件开头定义了一组基础类型,用于约束 defu 接受的输入:
export type Input = Record<string | number | symbol, any>;
export type IgnoredInput =
| boolean
| number
| null
| any[]
| Record<never, any>
| undefined;
export type Merger = <T extends Input, K extends keyof T>(
object: T, key: keyof T, value: T[K], namespace: string,
) => any;
type nullish = null | undefined | void;
Input 是"普通对象"的类型层面等价物——任何具有字符串、数字或 symbol 键的 record。IgnoredInput 则代表那些在运行时会被 _defu 静默跳过的非对象值(回顾 _defu 第 11 行的 if (!isPlainObject(defaults)) 守卫)。nullish 类型囊括了三种底层类型,运行时将它们视为"无意见——使用默认值"。
IgnoredInput 中的 Record<never, any> 值得关注——它是 TypeScript 中 {} 的类型。当你将一个空对象作为某个默认值传入时,它应被视为没有任何属性可贡献,而 IgnoredInput 正好允许元组递归跳过它。
Defu<S, D>:可变参数元组递归
这是用户直接接触的顶层类型(通过导出的 Defu 类型直接使用,或隐式地通过 defu() 的返回类型获得):
export type Defu<
S extends Input,
D extends Array<Input | IgnoredInput>,
> = D extends [infer F, ...infer Rest]
? F extends Input
? Rest extends Array<Input | IgnoredInput>
? Defu<MergeObjects<S, F>, Rest>
: MergeObjects<S, F>
: F extends IgnoredInput
? Rest extends Array<Input | IgnoredInput>
? Defu<S, Rest>
: S
: S
: S;
这与运行时的 Array.reduce 形成了镜像关系。在运行时,arguments_.reduce((p, c) => _defu(p, c, "", merger), {}) 从左向右折叠参数列表;在类型层面,Defu 使用 TypeScript 的可变参数元组模式 [infer F, ...infer Rest],每次剥离一个默认值类型。
flowchart TD
Start["Defu<S, [D1, D2, D3]>"] --> Extract["D = [infer F=D1, ...Rest=[D2,D3]]"]
Extract --> Check{"F extends Input?"}
Check -->|Yes| Merge1["Defu<MergeObjects<S, D1>, [D2, D3]>"]
Check -->|No| CheckIgnored{"F extends IgnoredInput?"}
CheckIgnored -->|Yes| Skip["Defu<S, [D2, D3]><br/>(skip this default)"]
CheckIgnored -->|No| Done1["S (bail out)"]
Merge1 --> Extract2["D = [infer F=D2, ...Rest=[D3]]"]
Extract2 --> Merge2["Defu<MergeObjects<MergeObjects<S,D1>, D2>, [D3]>"]
Merge2 --> Extract3["D = [infer F=D3, ...Rest=[]]"]
Extract3 --> Final["D3 extends Input → MergeObjects<..., D3><br/>Rest is [] → base case"]
核心思路在于:如果 F(当前的默认值类型)是 IgnoredInput(如 null、boolean 或 undefined),递归会直接跳过它,继续处理 Rest。这正是运行时 if (!isPlainObject(defaults)) return _defu(baseObject, {}, ...) 的类型层面等价物——非对象值一律忽略。
提示: 如果你要构建可变参数泛型类型,
[infer F, ...infer Rest]模式就是你的递归原语。务必包含基本情况(这里:D extends []隐式返回S),以避免无限类型递归。
MergeObjects:逐键类型合并
这是整个系统的核心——一个映射类型,遍历源类型与默认值类型之间的每一个共有键,并应用空值感知的合并逻辑:
export type MergeObjects<
Destination extends Input,
Defaults extends Input,
> = Destination extends Defaults
? Destination
: Omit<Destination, keyof Destination & keyof Defaults> &
Omit<Defaults, keyof Destination & keyof Defaults> & {
-readonly [Key in keyof Destination &
keyof Defaults]: Destination[Key] extends nullish
? Defaults[Key] extends nullish
? nullish
: Defaults[Key]
: Defaults[Key] extends nullish
? Destination[Key]
: Merge<Destination[Key], Defaults[Key]>;
};
让我们逐部分拆解。
短路逻辑:Destination extends Defaults ? Destination。 如果目标类型已经是默认值类型的子类型(即它已满足默认值的所有约束),则无需合并,直接返回 Destination。这是一种优化,避免了不必要的类型计算。
三部分交叉类型。 对于不重叠的键,类型处理很直接:
Omit<Destination, keyof Destination & keyof Defaults>— 只属于 Destination 的键Omit<Defaults, keyof Destination & keyof Defaults>— 只属于 Defaults 的键
对于重叠的键(keyof Destination & keyof Defaults),映射类型会应用空值决策树:
flowchart TD
K["Key in both Destination and Defaults"] --> DestNull{"Destination[Key]<br/>extends nullish?"}
DestNull -->|Yes| DefNull{"Defaults[Key]<br/>extends nullish?"}
DefNull -->|Yes| BothNull["nullish"]
DefNull -->|No| UseDefault["Defaults[Key]"]
DestNull -->|No| DefNull2{"Defaults[Key]<br/>extends nullish?"}
DefNull2 -->|Yes| UseDest["Destination[Key]"]
DefNull2 -->|No| DeepMerge["Merge<Destination[Key], Defaults[Key]>"]
这与运行时逻辑完全一致:如果源值为 null/undefined,则使用默认值;如果默认值为 null/undefined,则使用源值;如果两者都不为空,则通过 Merge 递归处理。-readonly 修饰符会去除键上的 readonly 约束,与运行时克隆对象后丢失只读限制的行为保持一致。
Merge:类型层面的派发链
Merge 是 _defu 内部决策树的类型等价物——负责处理数组、函数、RegExp、Promise 以及普通对象:
整个派发链是一个嵌套条件类型,依次检查 Destination 和 Defaults:
- 空值派发(第 79–84 行):如果任意一侧为空值,返回另一侧(若两侧均为空值则返回
nullish)。 - 数组派发(第 86–89 行):若两侧均为数组,使用
MergeArrays;若只有一侧为数组,产生联合类型。 - 函数/RegExp/Promise 守卫(第 92–105 行):若任意一侧是 Function、RegExp 或 Promise,产生联合类型
Destination | Defaults,不进行深度合并。 - 对象递归(第 107–111 行):若两侧均继承自
Input,通过MergeObjects递归处理;否则产生联合类型。
函数/RegExp/Promise 的检查逻辑对应运行时的 isPlainObject 守卫。在运行时,isPlainObject(new Date()) 返回 false,因此 Date 对象永远不会被深度合并。在类型层面,Date 虽然不继承自 Function,但它也不会以触发 MergeObjects 的方式继承自 Input——最终会走到 Destination | Defaults 联合类型的兜底分支。
注意,这些检查是重复的——先检查 Destination(第 92–97 行),再检查 Defaults(第 100–105 行)。这是因为只要任意一侧不可合并,就应阻止深度递归。如果将 { a: () => void } 与 { a: { nested: true } } 合并,结果类型应为 (() => void) | { nested: true },而不是一个合并后的对象。
MergeArrays:建模数组拼接
数组类型的实现简洁明了:
export type MergeArrays<Destination, Source> = Destination extends Array<
infer DestinationType
>
? Source extends Array<infer SourceType>
? Array<DestinationType | SourceType>
: Source | Array<DestinationType>
: Source | Destination;
在运行时,_defu 会拼接数组:[...value, ...object[key]],结果数组包含来自两个来源的元素。TypeScript 将此建模为 Array<DestinationType | SourceType>——元素类型是两个数组元素类型的联合。
test/defu.test.ts#L42-L52 中的测试验证了这一点:
const item1 = { name: "Name", age: 21 };
const item2 = { name: "Name", age: "42" };
const result = defu({ items: [item1] }, { items: [item2] });
expectTypeOf(result).toMatchTypeOf<{
items: Array<
{ name: string; age: number } | { name: string; age: string }
>;
}>();
运行时数组为 [item1, item2],对应的类型为 Array<{name: string; age: number} | {name: string; age: string}>。类型系统无法得知元素的顺序或数量,但它正确地表达了数组中的元素可能来自任一来源。
运行时 ↔ 类型对照表
以下是运行时决策与类型层面实现的完整对应关系:
_defu 中的运行时决策 |
类型层面的对应实现 | 位置 |
|---|---|---|
if (!isPlainObject(defaults)) — 跳过非对象 |
Defu 中的 F extends IgnoredInput ? Defu<S, Rest> : S |
types.ts L44-L47 |
value === null || value === undefined — 跳过空值 |
MergeObjects 中的 Destination[Key] extends nullish ? Defaults[Key] |
types.ts L27-L30 |
Array.isArray(value) && Array.isArray(object[key]) — 拼接 |
Merge 中的 Destination extends Array<any> ? ... MergeArrays |
types.ts L86-L89 |
isPlainObject(value) && isPlainObject(object[key]) — 递归 |
Merge 中的 Destination extends Input ? Defaults extends Input ? MergeObjects |
types.ts L107-L109 |
| 其他情况 — 使用源值覆盖 | 整个 Merge 中的 Destination | Defaults(联合类型兜底) |
types.ts L89, L93 等 |
Object.assign({}, defaults) — 克隆默认值 |
MergeObjects 中的 Omit<Defaults, shared keys> — 仅属于 defaults 的键得以保留 |
types.ts L25 |
arguments_.reduce(...) — 左折叠 |
Defu<MergeObjects<S, F>, Rest> — 递归元组剥离 |
types.ts L42 |
唯一有意为之的差异在于覆盖情况。在运行时,如果源值非空且默认值也非空但类型不同(例如一个是函数,另一个是数字),源值获胜——输出就是源值。但在类型层面,由于无法得知运行时的具体值,我们产生 Destination | Defaults——两种可能类型的联合。这是合理的选择:类型表达"可能是其中之一",而运行时会将其收窄为具体的值。
提示: 在类型层面建模运行时行为时,对于类型系统无法确定执行哪个分支的情况,联合类型是你的最佳选择。联合类型始终是健全的;而硬选一侧的类型,一旦运行时走向另一侧,就会产生不健全的结果。
DefuInstance 接口
对外导出的 defu 不只是一个函数——它是一个 DefuInstance,同时还携带 fn、arrayFn 和 extend 属性:
export interface DefuInstance {
<Source extends Input, Defaults extends Array<Input | IgnoredInput>>(
source: Source | IgnoredInput,
...defaults: Defaults
): Defu<Source, Defaults>;
fn: DefuFn;
arrayFn: DefuFn;
extend(merger?: Merger): DefuFn;
}
调用签名允许 source 为 Source | IgnoredInput——你可以将 null 或 undefined 作为第一个参数传入,运行时会正确处理,类型系统也会通过 Defu 正确推断。...defaults 剩余参数的类型 Defaults extends Array<Input | IgnoredInput> 启用了可变参数元组推断,正是这一机制驱动了 Defu<S, D> 的运作。
使用 expectTypeOf 进行类型层面测试
Defu 的测试套件不仅测试运行时值,还测试类型。每个创建合并结果的 it() 块都会使用 expect-type 库的 expectTypeOf 对推断出的类型进行断言:
const result = defu({ a: "c" }, { a: "bbb", d: "c" });
expect(result).toEqual({ a: "c", d: "c" });
expectTypeOf(result).toMatchTypeOf<{ a: string; d: string }>();
更复杂的场景包括测试多默认值推断(test/defu.test.ts#L92-L104)、带 undefined 源值的部分合并(test/defu.test.ts#L124-L164),以及基于接口的配置合并:
interface SomeConfig { foo: string; }
interface SomeOtherConfig { bar: string[]; }
interface ThirdConfig { baz: number[]; }
interface ExpectedMergedType { foo: string; bar: string[]; baz: number[]; }
expectTypeOf(
defu({} as SomeConfig, {} as SomeOtherConfig, {} as ThirdConfig),
).toMatchTypeOf<ExpectedMergedType>();
这些 expectTypeOf 断言不在运行时执行——它们是纯编译时检查。这也是为什么 tsc --noEmit 是一个独立的 CI 步骤(正如我们在第 1 篇中介绍的)。Vitest 负责执行 expect() 断言;TypeScript 负责验证 expectTypeOf() 断言。两者都必须通过。
flowchart LR
subgraph "vitest run"
A["expect(result).toEqual(...)"] --> B["Runtime correctness ✓"]
end
subgraph "tsc --noEmit"
C["expectTypeOf(result).toMatchTypeOf<T>()"] --> D["Type correctness ✓"]
end
B --> E["CI green"]
D --> E
这种双重验证策略对于 defu 这样的库至关重要。类型回归(运行时仍然正常,但推断出的类型变为 any 或过于宽泛)会悄无声息地破坏所有下游消费者的类型检查。通过显式地测试类型,defu 能够在问题发布之前就将其拦截。
系列总结
纵观这三篇文章,我们从架构骨架到算法核心,再到类型层面的运作机制,完整地追溯了 defu 的全貌。这个库堪称设计典范,展示了约 100 行运行时代码能够承载多少精心的设计:
- 第 1 篇 介绍了工厂模式、CJS/ESM 双模式发布,以及将类型视为一等公民的 CI 流水线。
- 第 2 篇 详细追踪了
_defu中的五路决策树、isPlainObject守卫、原型污染防护,以及策略模式的 merger 钩子。 - 第 3 篇 揭示了每一个运行时决策如何在递归条件类型中得到镜像,从而产生精确的合并类型,并将其传递到下游消费者。
这背后有一个更宏观的架构启示:当你的运行时逻辑具有清晰的决策分支时,你完全可以(也往往应该)在类型系统中建模同样的分支。Defu 的 112 行类型文件并非偶然的复杂性——它是在框架生态中实现类型安全深度默认值所必须付出的代价。在这个生态里,配置对象层层嵌套、参数数量可变,且往往只有部分字段被定义。而这份投入的回报,体现在每一位 Nuxt 用户在合并后的配置上获得正确自动补全的那一刻。