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 的模糊结构。
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 库统一生成校验结果与类型。
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 字符串,因为截止日期在这里表示日历日期,没有时分秒或时区。是否允许过去日期由独立业务规则处理。
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 会被拒绝;身份与状态流转不属于这个编辑接口。
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。明确是否接受过去日期,并分别解释结构校验和业务规则。
参考思路
先用正则约束文本格式,再按 UTC 解析并把年月日格式化回来比对,避免 JavaScript 日期自动进位掩盖错误。是否允许过去日期由任务业务决定,应单独说明。加入有效闰日、无效闰日、缺失字段和非字符串输入测试。输出保持日期字符串,避免不必要的时区转换。
验收与自测#
- 对正常、缺失、类型错误、越界和额外字段都有明确处理。
- 能说明类型断言为什么不会校验网络响应。
- 能区分格式合法、业务合法和权限允许。
问:用 shared/types 就不需要后端校验了吗? 答:需要,类型只约束参与编译的代码。
问:JSON Schema 通过就说明模型回答正确吗? 答:不说明,仍需验证依据与业务规则。
问:所有 API 都应该拒绝未知字段吗? 答:应根据请求与响应方向及兼容策略决定。