为 TypeScript 项目建立可靠的类型边界:API 响应、表单与第三方库 原文链接为 TypeScript 项目建立可靠的类型边界API 响应、表单与第三方库TypeScript 的类型系统很擅长描述我们写出的代码应当如何协作但它不能证明网络响应、用户输入或第三方 SDK 的实际返回值符合预期。问题通常从一行看似无害的代码开始const user (await response.json()) as User;这里的as User不会校验 JSON也不会在数据缺字段、字段类型错误或服务端悄悄变更时抛出异常。类型断言会在编译后被移除非空断言!也是同样的编译期承诺。它们只能告诉编译器“相信我”不能把不可信数据变成可信事实。可靠的做法不是在每个调用点补更多断言而是在数据进入业务逻辑前建立类型边界凡是 TypeScript 编译器无法证明来源和形状的数据都是边界输入。这包括 HTTP/API 响应、表单和 URL 参数、本地存储、环境变量、消息队列以及类型不完整或行为不稳定的第三方库。统一模型先承认未知再形成可信类型边界层应遵循一条单向数据流外部输入 unknown → 解析、结构校验、规范化 → DTO 或命令对象 → 领域不变量校验与转换 → 可信领域类型 → 业务逻辑失败路径则应返回可识别的结构化错误例如网络失败、HTTP 协议失败、响应体读取或 JSON 解析失败、数据契约失败、业务规则失败不要把它们混成一个笼统的Error。unknown是边界输入的默认类型。它要求代码在读取属性、调用方法或赋值给具体类型前进行缩小any则会关闭检查并沿调用链扩散。换句话说unknown把不确定性留在入口any把不确定性带进系统核心。type ValidationIssue { path: string; code: string; message: string; }; type ResultT | { ok: true; value: T } | { ok: false; issues: ValidationIssue[] };业务服务只接收已验证的T边界层负责把原始值转换为ResultT。这样“为什么这个值可信”会保留在代码结构中而不是藏在一处as里。API 响应HTTP 成功不等于数据可信fetch()在网络错误等情况下会拒绝但服务端返回404、500等状态时Promise 通常仍会得到一个Response。因此API 边界至少有四层检查传输层网络中断、超时、取消协议层状态码是否成功、响应是否为预期媒体类型数据契约层响应体能否读取和解析为 JSON字段结构是否符合约定领域层数据是否满足业务不变量。下面以“订单摘要”为例。服务端 DTO 使用字符串表示金额和时间而业务层希望使用经过规范化的值import { z } from zod; const OrderDtoSchema z.object({ id: z.string().min(1), total: z.string().regex(/^\d(\.\d{1,2})?$/), currency: z.string().regex(/^[A-Za-z]{3}$/), createdAt: z.string().datetime(), }); type Order { id: string; totalCents: number; currency: string; createdAt: Date; }; function toOrder(input: unknown): ResultOrder { const parsed OrderDtoSchema.safeParse(input); if (!parsed.success) { return { ok: false, issues: parsed.error.issues.map((issue) ({ path: issue.path.join(.), code: issue.code, message: issue.message, })), }; } const dto parsed.data; const createdAt new Date(dto.createdAt); const totalCents Math.round(Number(dto.total) * 100); const currency dto.currency.toUpperCase(); if (!Number.isSafeInteger(totalCents) || Number.isNaN(createdAt.valueOf())) { return { ok: false, issues: [{ path: , code: domain_invalid, message: 订单数据不满足领域规则 }], }; } return { ok: true, value: { id: dto.id, totalCents, currency, createdAt }, }; } async function fetchOrder(id: string): PromiseResultOrder { let response: Response; try { response await fetch(/api/orders/${encodeURIComponent(id)}); } catch { return { ok: false, issues: [{ path: , code: network_error, message: 网络请求失败 }] }; } if (!response.ok) { return { ok: false, issues: [{ path: , code: http_error, message: HTTP ${response.status} }] }; } const contentType response.headers.get(content-type) ?? ; if (!contentType.includes(application/json)) { return { ok: false, issues: [{ path: , code: unexpected_content_type, message: 响应不是 JSON }], }; } let body: unknown; try { // response.json() 在 TypeScript 的 DOM 类型中通常是 Promiseany // 显式接收为 unknown避免 any 继续传播。 body await response.json(); } catch { return { ok: false, issues: [{ path: , code: invalid_json, message: 响应体无法读取或解析为 JSON }], }; } return toOrder(body); }这里要刻意区分DTO与领域模型。DTO 是外部契约的镜像允许保留字符串日期、字段别名、null、供应商枚举值等现实细节领域模型则应表达业务真正需要的形式例如分单位金额、有效日期和值对象。两者相同只是偶然不应成为默认设计。示例为简洁起见使用Number(dto.total) * 100转换金额并通过安全整数检查拦截过大值。涉及计费、结算或任意精度金额时应使用整数分单位传输或采用十进制定点/高精度库不要把二进制浮点运算当作精确金额模型。对于可演进 API尤其要决定未知值策略核心流程遇到未知枚举值可以失败并报警展示型字段则可映射为unknown并保留原始值。关键不是“可选字段越多越兼容”而是明确每种变化会中止、降级还是兼容。表单浏览器交付的是原始输入不是业务命令即使input typenumber看起来是数字表单提交时仍要面对字符串、空值和文件。FormData的每个条目是string或File通过FormData.append()写入的非Blob值会被转换为字符串。因此应把表单处理拆成两步FormData / UI state → 原始表单值 → 规范化与校验 → 可提交命令const SignupSchema z.object({ email: z.string().trim().email(), password: z.string().min(12), confirmPassword: z.string(), age: z.coerce.number().int().min(18), }).refine((value) value.password value.confirmPassword, { path: [confirmPassword], message: 两次密码输入不一致, }); type SignupCommand z.outputtypeof SignupSchema; function parseSignup(formData: FormData): ResultSignupCommand { // 此表单的字段均为单值文本字段。含文件或同名多值字段时 // 应显式使用 get、getAll 并分别定义对应的 schema避免 Object.fromEntries 丢失重复值。 const raw: unknown Object.fromEntries(formData.entries()); const result SignupSchema.safeParse(raw); return result.success ? { ok: true, value: result.data } : { ok: false, issues: result.error.issues.map((issue) ({ path: issue.path.join(.), code: issue.code, message: issue.message, })), }; }这个边界承担三项职责规范化trim()、空字符串转缺失值、字符串转数字字段规则邮箱格式、长度、范围、文件类型与大小跨字段规则确认密码、日期区间、金额与币种组合。客户端校验应尽早给出反馈、映射字段错误并管理提交状态但它不是安全边界。用户可以修改 DOM、直接构造请求或绕过浏览器约束服务端必须把收到的内容重新当作unknown校验。输入校验也不替代认证、授权、速率限制或文件内容安全检测。第三方库把不可靠类型关在适配层第三方 SDK 的.d.ts文件只能描述静态接口不能保证运行时返回值正确有些遗留 JavaScript 包甚至会以any进入项目。解决办法不是让核心业务“接受现实”而是建立 adapter 或 facade供应商 SDK / 遗留 JS → adapter最小检查、错误翻译、字段映射 → 本地稳定接口 → 业务服务type PaymentStatus paid | pending | failed; type PaymentGateway { getStatus(transactionId: string): PromisePaymentStatus; }; function isRecord(value: unknown): value is Recordstring, unknown { return typeof value object value ! null; } function hasQueryMethod( value: unknown, ): value is { query(id: string): Promiseunknown } { return isRecord(value) typeof value.query function; } function isPaymentStatus(value: unknown): value is PaymentStatus { return value paid || value pending || value failed; } export function createPaymentGateway(vendorSdk: unknown): PaymentGateway { if (!hasQueryMethod(vendorSdk)) { throw new Error(支付供应商 SDK 不提供 query 方法); } return { async getStatus(transactionId) { let raw: unknown; try { raw await vendorSdk.query(transactionId); } catch (cause) { // 实际项目可在这里转换为本地定义的 VendorRequestError // 并保留 cause 供日志或诊断使用。 throw new Error(支付供应商请求失败, { cause }); } if (!isRecord(raw) || !isPaymentStatus(raw.status)) { throw new Error(支付供应商返回了无法识别的状态); } return raw.status; }, }; }适配器必须同时验证调用能力和返回数据。仅用类型断言把unknown写成带有query()方法的对象无法保证运行时该方法确实存在一旦供应商 SDK 初始化异常错误仍会以无关的TypeError泄漏到业务层。更理想的做法是为 SDK 补充局部声明或用 schema 完整校验其输出无论采用哪种方案业务模块都不应直接依赖供应商 DTO、any或供应商特有错误码。手写校验、Schema 与代码生成按边界复杂度选择没有一种方案适合全部入口。路径适用情况代价与注意点手写 type guard / assertion function字段少、性能敏感、不能引入依赖容易重复复杂嵌套与错误信息维护成本高Schema 校验库多入口复用、需要结构化错误、需要输入输出转换增加运行时依赖与包体积需要管理 schema 演进OpenAPI / JSON Schema / 代码生成契约由多团队或服务端统一维护仅生成 TypeScript 类型不等于运行时验证仍要决定验证位置手写校验的关键是先检查运行时事实再让 TypeScript 收窄function assertNonEmptyString(value: unknown, field: string): asserts value is string { if (typeof value ! string || value.trim() ) { throw new Error(${field} 必须是非空字符串); } }Schema 方案适合将“规则、推导类型、错误路径、转换”集中管理。以 Zod 为例safeParse()可返回区分成功与失败的结果schema 的输入类型和输出类型也可不同适合边界上的“校验后转换”。但不要为了使用库而把简单的两字段检查复杂化。错误模型与可观测性把契约漂移变成可发现事件边界失败不应只记录“解析失败”。建议至少记录来源、接口或供应商名、字段路径、错误码、预期类型、实际类型、契约版本或应用版本。同时避免把完整请求体、认证令牌、密码、身份证明或支付信息直接写入日志。对于线上告警更有价值的是聚合指标例如api_contract_error_total{endpoint/orders}vendor_payload_invalid_total{vendorpayment-x}表单字段错误的分布与提交失败率。这能把“偶发线上异常”转化为可观测的契约漂移后端字段改名、第三方新增状态、BFF 发布不同步都能更早暴露。落地顺序先封住高风险入口不必一次重写所有类型。可以按风险逐步推进开启strict并酌情启用noUncheckedIndexedAccess、useUnknownInCatchVariables等选项减少新的不安全假设盘点fetch().json() as ...、as any、第三方 SDK 直连和表单直接提交优先治理支付、权限、订单、身份信息、Webhook 与关键配置入口为每个解析器测试合法样本、非法样本和契约变更样本让可信领域类型只在边界成功后产生避免业务层回流使用原始 DTO。类型边界的目标不是消灭所有断言也不是给每个对象加一层 schema目标是让不可信数据只能在有限、可测试、可观测的位置存在。一旦数据跨过边界业务代码就可以真正相信它的类型。参考资料TypeScriptEveryday Types类型断言、any与非空断言TypeScriptNarrowing运行时检查与类型收窄MDNUsing the Fetch API状态码、内容类型与 JSON 解析MDNUsing FormData Objects表单值、字符串与文件MDNConstraint Validation客户端与服务端校验ZodBasic usagesafeParse、类型推导与转换