Schema 校验与 defineTool 模式

专栏:TypeScript 与 Node 地基 · 第 7 / 13 篇
TypeScriptSchema校验

:::info 学习目标 完成本篇后你能够:用 zod 定义 schema 并自动推导 TS 类型;理解运行时校验与编译期类型为什么缺一不可;亲手实现一个迷你版 defineTool。 前置:第 5~6 篇完成。预计时长:60 分钟。 :::

边界上的数据都是不可信的:用户输入、API 响应、模型的工具调用参数。第 1 篇说过类型在运行时会被擦除——所以编译期类型再正确,也挡不住运行时来了个畸形参数。解法:用一份 schema 同时生成”编译期类型”和”运行时校验器”

zod:一份 schema,两份收益

import { z } from 'zod'

const ReadFileArgs = z.object({
  path: z.string().describe('要读取的文件绝对路径'),
  limit: z.number().int().positive().optional(),
})

type ReadFileArgs = z.infer<typeof ReadFileArgs>
// 自动得到:{ path: string; limit?: number | undefined }

const parsed = ReadFileArgs.safeParse({ path: '/etc/hosts' })
if (!parsed.success) {
  console.log(parsed.error.issues)   // 哪个字段、什么原因、期望什么
} else {
  parsed.data.path                   // 类型完整,值已通过校验
}

z.infer 是泛型魔法(上一篇的技巧):schema 常量 → 推导出 TS 类型。schema 是唯一事实源:改 schema,类型与校验同时变,永不脱节。另外 .describe() 的描述还能序列化成 JSON Schema——正是模型可见的工具参数说明(第 5 篇的工具描述)。

亲手实现迷你版 defineTool

把上一篇的泛型用起来,30 行实现 dsh defineTool 的核心机制:

import { z, ZodType } from 'zod'

interface ToolDef<Args> {
  name: string
  description: string
  schema: ZodType<Args>                              // schema 决定 Args 类型
  execute: (args: Args) => Promise<string>
}

function defineTool<Args>(def: ToolDef<Args>) {
  return {
    ...def,
    // 模型调用入口:先校验,再执行——校验失败返回可读错误而不是崩溃
    async handle(rawArgs: unknown): Promise<string> {
      const parsed = def.schema.safeParse(rawArgs)
      if (!parsed.success) {
        return `Error: 参数不合法 - ${parsed.error.issues
          .map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`
      }
      return def.execute(parsed.data)
    },
  }
}

const readFileTool = defineTool({
  name: 'read_file',
  description: '读取文本文件',
  schema: z.object({ path: z.string() }),
  async execute(args) {
    return args.path                                  // args 有完整类型提示
  },
})

await readFileTool.handle({ path: '/etc/hostname' })  // ✓ 正常执行
await readFileTool.handle({ limit: 3 })               // → "Error: 参数不合法 - path: Required"

对照 dsh 的真实 defineTool:它多做了三件事——参数校验失败的结构化错误码INVALID_ARGS,回流给模型而非炸轮次)、输出值也要过 schema 校验、从 schema 自动生成模型可见的参数描述。机制完全相同,工程细节更厚。

校验失败的错误信息:给模型看的

上例的错误信息直接拼接字段与原因——它是回流给模型的,不是给人类调试的。好的校验错误能让模型下一轮自我纠正(第 4 篇”错误回填”原则),差的校验错误(如裸抛 TypeError)会让模型反复撞墙。

校验在调用链上的位置

图表(ts-schema-validation.md)

常见踩坑

常见踩坑

  1. schema 与实际校验脱节——手写校验 + 单独维护类型,改一处忘另一处;用 z.infer 从 schema 推导类型,永不脱节;
  2. describe 缺失——schema 里没有 .describe(),转成 JSON Schema 后模型只能靠参数名猜含义;
  3. 在校验前展开不可信输入——execute({...raw}) 先展开再校验等于没校验,先 safeParse 再使用;
  4. 对返回值不校验——dsh 连输出都有 schema(output.schema),返回什么、渲染什么都有契约。

随堂练习(带验收标准)

  1. read_filelimit(可选正整数)与 offset(可选非负整数)字段。验收:limit: -1 返回可读校验错误;
  2. 给工具加 output.schema(返回值类型校验)。验收:execute 返回值不符时得到结构化错误;
  3. 打开 dsh 的 packages/core/tools/src/schema.ts,找出 defineTool 做了哪三件你本篇没做的事。

← 返回文章列表