# TypeScript 与接口契约

## TypeScript 为什么不能替代接口校验

你可能已经习惯给接口写 `interface`。但 TypeScript 的类型在编译后大多被移除，服务器、用户输入和模型返回的数据不会因为一个类型断言就变得可靠。`await response.json() as Answer` 只是告诉编译器信任你，不会验证运行时对象是否包含答案、引用是否为数组、金额是否有效。

本章目标是 L2：区分静态类型、运行时校验和业务规则，并把它们组织成清晰的接口契约。前置知识是 TypeScript 基础与 JSON，不要求掌握复杂泛型技巧。对 AI 产品尤其重要，因为结构正确的输出也可能在事实或权限上不成立。

## 契约至少包含什么

一个“创建任务”的 API 不只包含字段名。它还应说明路径与方法、身份来源、字段约束、成功响应、错误码、超时、幂等行为和版本兼容。用户文本是否可以为空、空格是否被裁剪、最大长度按字节还是字符计算，都会影响前后端一致性。

| 层次 | 解决的问题 | 示例 |
| --- | --- | --- |
| 静态类型 | 自己的代码是否正确使用数据 | `title` 在编译时必须是 string |
| 运行时结构校验 | 外部输入是否符合数据形状 | JSON 对象里是否真的存在 title |
| 业务校验 | 这个值在当前业务中是否允许 | 用户是否有权向该项目创建任务 |
| 持久化约束 | 并发下是否仍保持一致 | 用户范围内幂等键必须唯一 |

这些层次互相补充。前端校验改善体验，服务端校验保护入口，数据库约束保护最终一致性。不要让共享 TypeScript 文件承担它无法执行的责任。

## 用可辨识联合表达状态

下面是 TypeScript 类型设计片段，供已有 TypeScript 项目使用，不是一个可单独执行的程序。它用 `kind` 区分成功与失败，避免出现 `ok: true` 却同时要求读取 error 的模糊结构。

```ts
type CreateTaskResult =
  | { kind: 'created'; taskId: string }
  | { kind: 'validation_error'; field: string; message: string }
  | { kind: 'forbidden'; message: string };

function describe(result: CreateTaskResult): string {
  switch (result.kind) {
    case 'created':
      return `任务已创建：${result.taskId}`;
    case 'validation_error':
      return `${result.field}：${result.message}`;
    case 'forbidden':
      return result.message;
    default: {
      // 添加新分支却忘了更新处理逻辑时，类型检查会报错。
      const unreachable: never = result;
      return unreachable;
    }
  }
}
```

联合类型让处理逻辑与业务状态对齐。不要用十几个可选字段表达互斥状态，随后在每个组件里猜测组合是否合法。状态越接近真实业务，错误就越容易在编译或测试阶段出现。

## 一个可以运行的边界校验器

保存为 `validate-task.mjs`，用 `node validate-task.mjs` 执行，无需依赖。这是业务规则很小的教学实现；规则较多时可使用成熟 schema 库统一生成校验结果与类型。

```js
import assert from 'node:assert/strict';

function parseCreateTask(input) {
  // JSON.parse 后仍是外部数据，先排除 null 和数组。
  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
    throw new TypeError('请求体必须是对象');
  }
  const allowedKeys = new Set(['title', 'priority']);
  if (Object.keys(input).some(key => !allowedKeys.has(key))) {
    throw new TypeError('存在不支持的字段');
  }
  if (typeof input.title !== 'string') {
    throw new TypeError('title 必须是字符串');
  }
  const title = input.title.trim();
  // 此处使用 Unicode 码点计数，不等同于用户感知的字符簇数量。
  if ([...title].length < 1 || [...title].length > 80) {
    throw new TypeError('title 长度应为 1 到 80 个码点');
  }
  const priority = input.priority ?? 'normal';
  if (!['low', 'normal', 'high'].includes(priority)) {
    throw new TypeError('priority 不在允许范围内');
  }
  // 显式创建新对象，只向后续业务层传递已经校验的字段。
  return { title, priority };
}

assert.deepEqual(parseCreateTask({ title: '  阅读事务章节  ' }), {
  title: '阅读事务章节', priority: 'normal',
});
assert.throws(() => parseCreateTask({ title: '  ' }), /title/);
assert.throws(() => parseCreateTask({ title: '任务', ownerId: 'someone' }), /字段/);
console.log('输入校验通过：正常、空值、额外字段三类场景');
```

`parseCreateTask` 的输入可以是任何运行时值；成功时返回规范化对象，失败时抛出 TypeError。校验器并没有确认当前用户是否有权创建任务，因为它没有会话和数据库上下文。业务服务还要执行权限检查。不要把 `ownerId` 直接接收为客户端字段，本例通过拒绝额外字段帮助暴露这类错误。

对公共长期 API，是否拒绝未知字段需要考虑兼容策略。内部创建请求严格校验更容易发现拼写错误；读取外部响应时，通常应允许服务端增加无害的新字段。规则需要按接口方向决定，不能把同一个“严格模式”不加区分应用到所有边界。

## AI 输出的三次检查

第一层检查 JSON 是否能解析；第二层检查字段形状和值域；第三层检查事实与权限。例如模型返回 `{documentId:'doc-8', quote:'可以报销'}`，它即使符合 schema，也可能引用不存在的文档，或引用用户无权阅读的内容。

应该把模型返回的文档 ID 与本次实际检索到、已通过权限过滤的资料集合比对。模型生成的工具参数也需要重新校验。类型、安全和事实是不同的问题，结构化输出只能解决其中一部分。

## 错误契约与可观测性

返回用户可理解的错误类别和可追踪请求 ID，不直接返回堆栈、SQL 或第三方凭据。例如 `INVALID_ARGUMENT` 表示用户可修改输入，`UPSTREAM_TIMEOUT` 表示依赖超时可稍后重试。HTTP 状态码与业务码需要一致的约定。

重试并不是所有错误的默认答案。400 类输入错误需要修正请求；身份过期需要重新登录；429 或暂时网络故障可以在预算内退避。涉及写操作时，必须先明确幂等，否则一次超时可能已经在服务器写成功，再重试就创建第二条数据。

## 从字段类型走到真正的边界设计

假设界面要提交一个任务：标题必填，截止日期可选，优先级默认普通。前端类型可以写得很简短，但接口仍要回答五个问题：缺失和 null 是否相同；空字符串是不是清空；默认值由谁补；长度按照什么单位计算；请求中的未知字段是否拒绝。只有这些问题有共同答案，前端、后端和数据库才是在实现同一份契约。

把输入边界想成漏斗。网络传来的 unknown 先经过结构检查，再得到规范化的命令；业务服务拿命令和可信身份做授权与业务判断；仓库把已经明确的字段映射为 SQL 参数。不要在 Vue 组件中判断一种空值，在服务层使用另一种默认值，最后让数据库默默补第三种值。这会让“界面显示的内容”和“实际保存的内容”不一致。

| 值 | 创建任务时的建议语义 | 更新任务时的可能语义 |
| --- | --- | --- |
| 字段不存在 | 使用文档中约定的默认值 | 不修改该字段 |
| null | 仅在契约允许空值时接受 | 明确清空可空字段 |
| 空字符串 | 对标题应拒绝 | 按字段规则拒绝或清空，不能猜测 |
| 数字字符串 | 通常不隐式当数字 | 前端先转换，服务端仍校验 |
| 未知字段 | 内部写接口可严格拒绝 | 避免误把字段拼写错误当成功 |

注意本章最初的示例使用 `input.priority ?? 'normal'`，因此缺失和 null 都会成为 normal。这是可运行的小例子所采用的规则，不代表所有业务都该这样做。若你的契约规定只有缺失可默认、null 必须报错，应使用 `input.priority === undefined`，并为 null 增加失败样例。学会从一行运算符反推实际接口语义，比记住某个写法更重要。

### 信任方向决定校验策略

创建请求通常由你的前端产生，未知字段很可能是 bug 或越权尝试，严格白名单有帮助。读取响应则往往需要向前兼容：服务端添加一个描述字段，旧客户端不应因此整个页面失效。工具参数来自模型，看起来像自家代码生成，实际仍属于外部输入。环境变量来自部署配置，也需要在启动时校验，不能因“由管理员填写”就绕过类型边界。

客户端输入 userId、tenantId、role 时要特别小心。某些接口可以允许选择目标资源，但执行者身份始终从经过验证的会话建立。客户端可以请求“编辑 doc-1”，不能靠 body 里的 role=admin 自己获得权限。即便 TS 类型没写 role，JavaScript 用户或手工 HTTP 请求照样能发送它。

### 为什么不要到处使用类型断言

类型断言是你向编译器作出的承诺，适合有外部证明而编译器难以理解的局部情况。它不能承担证据本身。把网络响应直接断言成 Answer，随后在十个页面访问 answer.citations，只会把一次边界错误推迟到不确定的界面位置。合理做法是在客户端 API 模块校验响应，组件消费已确认的领域数据，错误则在统一边界转换为可展示状态。

也不必追求“一份 schema 自动生成一切”作为起点。你应先知道哪些规则能被结构描述表达，哪些必须访问数据库。例如“title 是字符串”容易共享，“该用户仍是这个空间成员”必须读取当前事实。统一 schema 能减少重复字段定义，但不能替代领域服务或事务。

## 完整练习答案：日期、空值与规范化

下面不是正则小片段，而是一份完整可执行的日期校验实验。保存为 `date-contract.mjs`，运行 `node date-contract.mjs`。日期范围限定为 0001 到 9999 年；输出保留 YYYY-MM-DD 字符串，因为截止日期在这里表示日历日期，没有时分秒或时区。是否允许过去日期由独立业务规则处理。

```js date-contract.mjs
import assert from 'node:assert/strict';

function parseDateOnly(value) {
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    throw new TypeError('DATE_FORMAT');
  }
  const [year, month, day] = value.split('-').map(Number);
  if (year < 1 || month < 1 || month > 12) throw new TypeError('DATE_RANGE');
  const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
  const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  if (day < 1 || day > days[month - 1]) throw new TypeError('DATE_RANGE');
  return value; // 不隐式转换为某个时区的午夜时间。
}

function parseTask(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw new TypeError('BODY_OBJECT_REQUIRED');
  }
  const allowed = new Set(['title', 'priority', 'dueDate']);
  if (Object.keys(input).some(key => !allowed.has(key))) throw new TypeError('UNKNOWN_FIELD');
  if (typeof input.title !== 'string') throw new TypeError('TITLE_TYPE');
  const title = input.title.trim();
  if ([...title].length < 1 || [...title].length > 80) throw new TypeError('TITLE_LENGTH');
  // 这个版本只有缺失值使用默认值，明确拒绝 null。
  const priority = input.priority === undefined ? 'normal' : input.priority;
  if (!['low', 'normal', 'high'].includes(priority)) throw new TypeError('PRIORITY_RANGE');
  const dueDate = input.dueDate === undefined ? null : parseDateOnly(input.dueDate);
  return { title, priority, dueDate };
}

function ensureNotPast(dueDate, today) {
  parseDateOnly(today);
  if (dueDate !== null && parseDateOnly(dueDate) < today) {
    throw new Error('DUE_DATE_IN_PAST');
  }
}

assert.equal(parseDateOnly('2028-02-29'), '2028-02-29');
assert.throws(() => parseDateOnly('2026-02-30'), /DATE_RANGE/);
assert.throws(() => parseDateOnly('1900-02-29'), /DATE_RANGE/);
assert.equal(parseDateOnly('2000-02-29'), '2000-02-29');
assert.throws(() => parseDateOnly('2026-9-1'), /DATE_FORMAT/);
assert.throws(() => parseTask({ title: '检查权限', priority: null }), /PRIORITY_RANGE/);
const task = parseTask({ title: '  检查权限  ', dueDate: '2026-09-11' });
assert.equal(task.title, '检查权限');
ensureNotPast(task.dueDate, '2026-09-10');
assert.throws(() => ensureNotPast('2026-09-09', '2026-09-10'), /IN_PAST/);
console.log('通过：日期格式、闰年、默认值、规范化与独立业务规则');
```

为什么 today 由调用者传入？若在函数内部直接读取机器时间，测试到明年就可能变化，跨时区部署时也可能产生不同结论。业务应先约定日期按用户时区、组织时区还是 UTC 解释，再把已经计算好的当天日期交给规则。这里只验证日期，不提供完整时区系统；实际项目应使用适合日期和时区运算的库并核对版本。

字符串比较在这里成立，是因为双方都通过了固定宽度的年、月、日校验。未经过校验的文本不具备这个性质。一个小规则经常依赖另一个规则作为前提，注释应把这种前提写出来，避免后来的人把函数拿去比较任意日期文本。

## 更新契约：避免把“没传”变成“清空”

PATCH 经常暴露前端到全栈的理解缺口。编辑表单只修改标题时，如果序列化时给所有可选字段补 null，服务端可能把描述和截止日期一起清空。反过来，若用 `value || oldValue` 合并，用户就无法把合法的数字零、false 或空描述写进去。你需要以字段是否存在决定更新意图，再按字段单独校验值。

下面定义两项允许修改的字段。description 可以为 null 表示清空，title 永远不能空。注意这是显式选择字段的更新策略，不是把请求体展开进数据库对象。用户传入 ownerId 或 status 会被拒绝；身份与状态流转不属于这个编辑接口。

```js patch-contract.mjs
import assert from 'node:assert/strict';

function parsePatch(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('BODY');
  const allowed = new Set(['title', 'description']);
  if (Object.keys(input).some(key => !allowed.has(key))) throw new TypeError('UNKNOWN_FIELD');
  const patch = {};
  if (Object.hasOwn(input, 'title')) {
    if (typeof input.title !== 'string' || !input.title.trim()) throw new TypeError('TITLE');
    if ([...input.title.trim()].length > 80) throw new TypeError('TITLE');
    patch.title = input.title.trim();
  }
  if (Object.hasOwn(input, 'description')) {
    if (input.description !== null && typeof input.description !== 'string') throw new TypeError('DESCRIPTION');
    if (typeof input.description === 'string' && input.description.length > 2000) throw new TypeError('DESCRIPTION');
    patch.description = input.description;
  }
  if (!Object.keys(patch).length) throw new TypeError('EMPTY_PATCH');
  return patch;
}

const old = { id: 'task-1', title: '旧标题', description: '应保留的说明', ownerId: 'alice' };
assert.equal(({ ...old, ...parsePatch({ title: '新标题' }) }).description, old.description);
assert.equal(({ ...old, ...parsePatch({ description: null }) }).description, null);
assert.equal(({ ...old, ...parsePatch({ description: '' }) }).description, '');
assert.throws(() => parsePatch({ title: null }), /TITLE/);
assert.throws(() => parsePatch({ ownerId: 'bob' }), /UNKNOWN_FIELD/);
assert.throws(() => parsePatch({}), /EMPTY_PATCH/);
console.log('通过：未修改、显式清空、空字符串、非法字段和空更新');
```

示例通过后，还不能说并发编辑安全。Alice 和 Bob 同时打开旧版本，先后提交标题时可能相互覆盖。可以增加 expectedVersion，并在数据库更新语句里同时匹配版本；影响零行时返回冲突，提示重新读取。这属于业务并发契约，需要真实数据库验证。本章的纯函数负责请求语义，不伪装成完整的持久化实现。

## 设计前后端都能使用的错误响应

错误至少包含稳定 code、可展示 message 和用于排查的 requestId。字段错误可以增加 path，但不要让前端靠解析自然语言识别错误。例如同样显示“无权访问”，内部需要区分登录失效与资源范围拒绝，才知道是跳转登录还是保留页面给出说明。

| 错误类别 | 示例 HTTP 状态 | 前端动作 | 是否直接重试 |
| --- | --- | --- | --- |
| INVALID_ARGUMENT | 400 或约定的 422 | 标出错误字段，保留输入 | 否，先改输入 |
| UNAUTHENTICATED | 401 | 更新登录状态，保留草稿 | 需要完成身份恢复 |
| FORBIDDEN | 403 | 解释权限不足 | 否 |
| NOT_FOUND | 404 | 展示资源不存在或不可见 | 一般否 |
| VERSION_CONFLICT | 409 | 重新读取并让用户选择 | 不能盲目覆盖 |
| RATE_LIMITED | 429 | 展示等待或额度状态 | 按策略及预算 |
| UPSTREAM_TIMEOUT | 504 或约定状态 | 保留输入并说明依赖超时 | 只在操作语义允许时 |

这是教材建议的应用映射，具体项目可以不同，但同一项目不能各接口任意变化。不要把所有服务器异常包成 HTTP 200；网关、客户端和监控很难准确识别失败。也不要把同一种业务错误的 message 当常量码，否则调整中文文案会破坏程序分支。

## API 版本变化时怎样判断兼容

新增一个可选响应字段通常较容易兼容；把 string 改成 number、修改枚举语义、改变分页排序或把原先可空字段变必填，都可能破坏旧客户端。新增枚举值尤其需要小心：TypeScript 的穷尽检查只保护参与编译的代码，已经安装的旧客户端并不会重新编译。未知值应有可理解的降级展示，涉及业务执行时则安全拒绝而不是猜测。

契约变更前准备三份样例：旧请求在新服务上能否继续工作，新响应在旧客户端上如何表现，以及未知或缺失字段怎样处理。OpenAPI 和 JSON Schema 可帮助描述和生成工具，但生成文件也需要评审。真正的兼容性是已有使用者的行为继续正确，不是 schema 文件能解析。

## 本章练到可以接手接口评审

拿一份真实接口，从输入来源开始口述一遍：哪一层从 unknown 得到类型，哪些默认值会改变输入，哪些字段依赖当前身份，哪些规则需要数据库，发生并发时谁作最终裁决。然后在不看答案的情况下写出缺失、null、零值、超长、未知字段和旧版本六组输入。

L2 要求你能独立完成上述校验与错误映射，接到不符合契约的数据时定位到边界。这里不要求编写复杂类型体操。对你的前端优势而言，能设计清晰的客户端状态、避免宽泛断言、把服务端失败解释给用户，比为了“全栈”堆很多泛型更直接有用。


## 练习

在示例中增加可选的 `dueDate`，格式为有效的 `YYYY-MM-DD`，不能接受 2026-02-30。明确是否接受过去日期，并分别解释结构校验和业务规则。

<details>
<summary>参考思路</summary>

先用正则约束文本格式，再按 UTC 解析并把年月日格式化回来比对，避免 JavaScript 日期自动进位掩盖错误。是否允许过去日期由任务业务决定，应单独说明。加入有效闰日、无效闰日、缺失字段和非字符串输入测试。输出保持日期字符串，避免不必要的时区转换。

</details>

## 验收与自测

- 对正常、缺失、类型错误、越界和额外字段都有明确处理。
- 能说明类型断言为什么不会校验网络响应。
- 能区分格式合法、业务合法和权限允许。

**问：用 shared/types 就不需要后端校验了吗？** 答：需要，类型只约束参与编译的代码。

**问：JSON Schema 通过就说明模型回答正确吗？** 答：不说明，仍需验证依据与业务规则。

**问：所有 API 都应该拒绝未知字段吗？** 答：应根据请求与响应方向及兼容策略决定。

## 官方参考

- [TypeScript：类型收窄](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
- [JSON Schema：学习资料](https://json-schema.org/learn)
- [MDN：HTTP 响应状态码](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
