接口、泛型与声明合并
专栏:TypeScript 与 Node 地基 · 第 6 / 13 篇:::info 学习目标
完成本篇后你能够:解释 dsh 的 ctx.tools 为什么有完整类型;读懂泛型约束与类型推导;亲手完成一次声明合并。
前置:第 1~4 篇完成。预计时长:60 分钟。
:::
读 dsh 源码时你会遇到这样的代码:插件 A 写了 ctx.tools,插件 B 写了 ctx.llm,两处的类型都完整——但它们从没互相 import 过对方的类型定义。这不是魔法,是 TypeScript 的三个特性叠加。
技巧一:接口声明合并(declaration merging)
TypeScript 允许同名 interface 多次声明,自动合并:
interface Window { title: string }
interface Window { width: number }
// 合并后:Window { title: string; width: number }
这看似冷门的特性,正是 dsh ctx 键机制的类型来源:
// packages/core/tools/src/index.ts(真实源码,节选)
declare module '@deepseek-ai/cordis' {
interface Context {
tools: ToolRuntime // 声明:所有 Context 上都有 tools 属性
}
}
// packages/llm/llm/src/index.ts(真实源码,节选)
declare module '@deepseek-ai/cordis' {
interface Context {
llm: LlmRuntime // 另一个包独立声明:还有 llm 属性
}
}
两个包互不 import,但它们的声明合并进同一个 Context 接口——每个插件向”全局类型”贡献一个键,全项目的 ctx.tools / ctx.llm 就都有了类型。这就是第 4 篇 Cordis 入门里”ctx 键”的类型学解释。
技巧一:接口声明合并
多个包各自声明同名 interface,TypeScript 自动把它们合并成一个大接口——这就是 dsh 的 ctx 键为什么”每个包都能加、全项目都有类型”:
技巧二:泛型与约束
技巧二:泛型与约束
泛型 = 类型的参数。最常用的场景:输入类型决定输出类型。
function first<T>(arr: T[]): T | undefined { // T 由调用处推断
return arr[0]
}
const n = first([1, 2, 3]) // n: number(自动推断)
const s = first(['a']) // s: string
泛型约束(extends)限制 T 的范围,并在函数体内获得该类型的字段:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key] // K 是 T 的键名,返回值类型自动对上
}
const user = { name: 'caiwei', age: 18 }
pluck(user, 'name') // ✓ 类型是 string
pluck(user, 'email') // ✗ 编译错误:user 没有 email
dsh 的 defineTool 用完全相同的手法做参数类型推导:你声明参数 schema,args 的类型自动跟着来——schema 是 source of truth,类型是免费的副产品。
技巧三:条件类型与 infer(读懂即可)
type Awaited<T> = T extends Promise<infer U> ? U : T
// Awaited<Promise<string>> = string;Awaited<number> = number
type Unwrap<T> = T extends Array<infer Item> ? Item : never
// Unwrap<string[]> = string
条件类型让类型系统具备”判断与提取”能力。读源码时见到 infer,翻译成”从这个类型里提取出 X”。
与 dsh 的对照:三技巧如何拼成一个工具
第 9 篇的 defineTool 完整体现三技巧:
// 简化示意
function defineTool<Args>(def: {
name: string
parameters: Schema<Args> // 泛型:schema 决定参数类型
execute: (args: Args, exec: ToolRunContext) => Promise<unknown>
}) { ... }
defineTool({
name: 'read_file',
parameters: { path: { type: 'string' } }, // ← schema
async execute(args, exec) { // ← args 自动推断为 { path: string }
return readFile(args.path) // ✓ args.path 有类型提示
},
})
再加上 ctx 键的声明合并,ctx.tools.register(...) 才能既有全局类型、又零耦合。
常见踩坑
- 以为
declare module会引入运行时代码——它纯属类型层,编译后消失;真正的挂载还是靠插件 apply; - 泛型滥用:只有一个调用处的函数不需要泛型,直接写具体类型更易读;
- 把
keyof与索引混用错——obj[K]的 K 必须是 T 的键名类型,拼错编译期就报错(这是保护不是麻烦)。
随堂练习(带验收标准)
- 仿照上面的
pluck,写getPath<T, K extends keyof T>(obj: T, key: K),约束 key 必须存在。验收:错误键名编译报错; - 建两个文件各自
declare module合并同一个 interface,在第三个文件里使用合并后的完整类型。验收:两个字段都有类型提示; - 打开
packages/core/tools/src/index.ts,找到ToolDefinition,找出哪些字段用了泛型/条件类型。