为 TypeScript 项目建立可靠的类型边界:API 响应、表单与第三方库
2026/8/17 2:16:51 网站建设 项目流程
原文链接

为 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 Result<T> = | { ok: true; value: T } | { ok: false; issues: ValidationIssue[] };

业务服务只接收已验证的T;边界层负责把原始值转换为Result<T>。这样,“为什么这个值可信”会保留在代码结构中,而不是藏在一处as里。

API 响应:HTTP 成功不等于数据可信

fetch()在网络错误等情况下会拒绝,但服务端返回404500等状态时,Promise 通常仍会得到一个Response。因此,API 边界至少有四层检查:

  1. 传输层:网络中断、超时、取消;
  2. 协议层:状态码是否成功、响应是否为预期媒体类型;
  3. 数据契约层:响应体能否读取和解析为 JSON,字段结构是否符合约定;
  4. 领域层:数据是否满足业务不变量。

下面以“订单摘要”为例。服务端 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): Result<Order> { 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): Promise<Result<Order>> { 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 类型中通常是 Promise<any>; // 显式接收为 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 type="number">看起来是数字,表单提交时仍要面对字符串、空值和文件。FormData的每个条目是stringFile;通过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.output<typeof SignupSchema>; function parseSignup(formData: FormData): Result<SignupCommand> { // 此表单的字段均为单值文本字段。含文件或同名多值字段时, // 应显式使用 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): Promise<PaymentStatus>; }; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } function hasQueryMethod( value: unknown, ): value is { query(id: string): Promise<unknown> } { 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{vendor="payment-x"}
  • 表单字段错误的分布与提交失败率。

这能把“偶发线上异常”转化为可观测的契约漂移:后端字段改名、第三方新增状态、BFF 发布不同步,都能更早暴露。

落地顺序:先封住高风险入口

不必一次重写所有类型。可以按风险逐步推进:

  1. 开启strict,并酌情启用noUncheckedIndexedAccessuseUnknownInCatchVariables等选项,减少新的不安全假设;
  2. 盘点fetch().json() as ...as any、第三方 SDK 直连和表单直接提交;
  3. 优先治理支付、权限、订单、身份信息、Webhook 与关键配置入口;
  4. 为每个解析器测试合法样本、非法样本和契约变更样本;
  5. 让可信领域类型只在边界成功后产生,避免业务层回流使用原始 DTO。

类型边界的目标不是消灭所有断言,也不是给每个对象加一层 schema;目标是让不可信数据只能在有限、可测试、可观测的位置存在。一旦数据跨过边界,业务代码就可以真正相信它的类型。

参考资料

  • TypeScript:Everyday Types(类型断言、any与非空断言)
  • TypeScript:Narrowing(运行时检查与类型收窄)
  • MDN:Using the Fetch API(状态码、内容类型与 JSON 解析)
  • MDN:Using FormData Objects(表单值、字符串与文件)
  • MDN:Constraint Validation(客户端与服务端校验)
  • Zod:Basic usage(safeParse、类型推导与转换)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询