接口、泛型与声明合并

专栏:TypeScript 与 Node 地基 · 第 6 / 13 篇
TypeScript泛型声明合并

:::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 键为什么”每个包都能加、全项目都有类型”:

图表(ts-generics-declaration-merging.md)

技巧二:泛型与约束

技巧二:泛型与约束

泛型 = 类型的参数。最常用的场景:输入类型决定输出类型

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(...) 才能既有全局类型、又零耦合。

常见踩坑

  1. 以为 declare module 会引入运行时代码——它纯属类型层,编译后消失;真正的挂载还是靠插件 apply;
  2. 泛型滥用:只有一个调用处的函数不需要泛型,直接写具体类型更易读;
  3. keyof 与索引混用错——obj[K] 的 K 必须是 T 的键名类型,拼错编译期就报错(这是保护不是麻烦)。

随堂练习(带验收标准)

  1. 仿照上面的 pluck,写 getPath<T, K extends keyof T>(obj: T, key: K),约束 key 必须存在。验收:错误键名编译报错;
  2. 建两个文件各自 declare module 合并同一个 interface,在第三个文件里使用合并后的完整类型。验收:两个字段都有类型提示;
  3. 打开 packages/core/tools/src/index.ts,找到 ToolDefinition,找出哪些字段用了泛型/条件类型。

← 返回文章列表