为 TypeScript 项目建立可靠的类型边界:API 响应、表单与第三方库

为 TypeScript 项目建立可靠的类型边界:API 响应、表单与第三方库

TypeScript 的类型系统很擅长描述我们写出的代码应当如何协作,但它不能证明网络响应、用户输入或第三方 SDK 的实际返回值符合预期。

问题通常从一行看似无害的代码开始:

ts 复制代码
const user = (await response.json()) as User;

这里的 as User 不会校验 JSON,也不会在数据缺字段、字段类型错误或服务端悄悄变更时抛出异常。类型断言会在编译后被移除;非空断言 ! 也是同样的编译期承诺。它们只能告诉编译器"相信我",不能把不可信数据变成可信事实。

可靠的做法不是在每个调用点补更多断言,而是在数据进入业务逻辑前建立类型边界

凡是 TypeScript 编译器无法证明来源和形状的数据,都是边界输入。

这包括 HTTP/API 响应、表单和 URL 参数、本地存储、环境变量、消息队列,以及类型不完整或行为不稳定的第三方库。

统一模型:先承认未知,再形成可信类型

边界层应遵循一条单向数据流:

text 复制代码
外部输入 unknown
  → 解析、结构校验、规范化
  → DTO 或命令对象
  → 领域不变量校验与转换
  → 可信领域类型
  → 业务逻辑

失败路径则应返回可识别的结构化错误,例如网络失败、HTTP 协议失败、响应体读取或 JSON 解析失败、数据契约失败、业务规则失败;不要把它们混成一个笼统的 Error

unknown 是边界输入的默认类型。它要求代码在读取属性、调用方法或赋值给具体类型前进行缩小;any 则会关闭检查,并沿调用链扩散。换句话说:unknown 把不确定性留在入口,any 把不确定性带进系统核心。

ts 复制代码
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 使用字符串表示金额和时间,而业务层希望使用经过规范化的值:

ts 复制代码
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 值会被转换为字符串。

因此应把表单处理拆成两步:

text 复制代码
FormData / UI state → 原始表单值 → 规范化与校验 → 可提交命令
ts 复制代码
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:

text 复制代码
供应商 SDK / 遗留 JS
  → adapter:最小检查、错误翻译、字段映射
  → 本地稳定接口
  → 业务服务
ts 复制代码
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 收窄:

ts 复制代码
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;目标是让不可信数据只能在有限、可测试、可观测的位置存在。一旦数据跨过边界,业务代码就可以真正相信它的类型。

参考资料

相关推荐
半仙er1 小时前
第一周02天 原型与原型链
前端
半仙er1 小时前
第一周04天Promise 深入与 async / await
前端
fail_to_code1 小时前
从 Lighthouse 83 到 100:一次 Vue 项目的性能排查实录
前端·人工智能
用户69371750013841 小时前
DeepSeek 调价正式生效:一夜涨 11 倍,靠低价薅羊毛的日子结束了
前端·人工智能·后端
半仙er1 小时前
第一周01天:this 指向与 call / apply / bind
前端
小帅不太帅1 小时前
给大家推荐一个特别好用的专为 AI Agent 打造的最快浏览器
前端·agent·浏览器
半仙er1 小时前
第一周03天 事件循环与宏任务 / 微任务
前端
做前端的娜娜子1 小时前
#浏览器存储方案:localStorage、sessionStorage 与 Cookie
前端·面试·掘金·金石计划
用户921080262861 小时前
1. Ant Design X Vue 项目介绍:结构、组件和启动方式
前端