TYPESCRIPT

TypeScript
的类型之外,
我更在意这三件事

类型能挡住错误,却挡不住糟糕的设计。边界、推导与错误模型,共同构成真正可靠的类型系统。

TypeScript 最容易被误解的一点,是它能让 JavaScript “变安全”。更准确的说法是:它让一部分错误提前显形,让意图在编译期被检查。但当数据从 API、URL、localStorage 或用户输入进入应用时,类型系统并不知道运行时真正发生了什么。

写得越久,我越少关注复杂类型技巧,而更在意三件更基础的事:边界是否可信、推导是否清晰、错误是否被正确表达。这三件事决定了类型系统是在帮助思考,还是只是在提供安全感。

第一件事:类型边界必须与运行时边界对齐

下面的代码在 TypeScript 看来完全合法:

unsafe.ts
type User = {
  id: string;
  name: string;
};

async function loadUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

问题在于 response.json() 返回的是 Promise<any>,而 any 可以悄悄流入 Promise<User>。如果服务端返回了错误结构、缺少字段,甚至是一段 HTML,调用者仍然会得到“这是一个 User”的错觉。

我会把外部数据先视为 unknown,再用运行时校验把未知变成已知。这里的校验可以来自手写守卫,也可以来自 Zod、Valibot 等 schema 库;重点不是选哪个工具,而是让信任有一个明确的产生点。

load-user.ts
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;

  const user = value as Record<string, unknown>;
  return typeof user.id === 'string'
    && typeof user.name === 'string';
}

async function loadUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const data: unknown = await response.json();
  if (!isUser(data)) throw new Error('Invalid user payload');

  return data;
}

注意,as Record<string, unknown> 只在校验函数内部使用,而且没有因此跳过字段检查。真正危险的是把一个未经检查的外部对象直接断言成业务类型,然后把 as 散落在调用链各处。

哪里值得加校验

API 响应、URL 参数、表单输入、持久化数据、第三方脚本消息,这些跨系统边界的位置都值得校验。内部函数之间的数据流如果已经被上一个边界验证过,不必重复防御。

第二件事:让推导和注解各自站在正确的位置

TypeScript 很容易让人走向两个极端:到处手写类型,或者一个类型也不写。我的经验是,契约边界显式,局部实现交给推导。

函数参数、导出的返回值、公共数据结构、组件 props,这些是别人依赖的契约,值得明确表达。内部变量、临时对象、实现细节,如果可以从上下文稳定推导,就不需要再写一遍。

tokens.ts
const tokens = {
  actionPrimary: '#d9ff57',
  actionPrimaryInk: '#111505',
  textMuted: '#a2a395',
} satisfies Record<string, string>;

// 保留具体的键,同时检查每个值都符合约束
type TokenName = keyof typeof tokens;

satisfies 很适合这种“我想验证对象形状,又不想丢失具体推导”的场景。如果改用 const tokens: Record<string, string>,编译器会忘记真实存在的键,TokenName 也就变成了普通的 string。

另一个常见问题是过度标注导致的重复真相。例如函数已经声明返回 User,函数体里的每个对象再手动断言一次 as User,既没有新增信息,还可能在内部掩盖错误。让类型从实现中自然推导,再由导出的签名约束结果,通常更可靠。

复杂类型应该被命名

如果一段类型需要三行泛型、两个条件类型才能读懂,它很可能缺少一个领域名称。把复杂类型收起为 PaymentState、SearchResponse 或 PermissionSet,比在十处重复展开更有价值。类型代码也是代码,需要为下一位读者设计。

类型体操可以是学习工具,但不应成为业务代码的展示方式。一个需要五分钟才能解释的泛型,带来的抽象收益通常比不上它增加的维护成本。

第三件事:把错误当成业务状态来建模

抛出异常并不总是错误设计,但把所有失败都塞进 try/catch,会让调用者难以知道究竟可能发生什么。对于业务上可预期的情况,例如校验失败、权限不足、库存为零,我更愿意把它们放进返回值里,让类型系统提醒每一个调用点。

result.ts
type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type CheckoutError =
  | { code: 'OUT_OF_STOCK'; productId: string }
  | { code: 'PAYMENT_DECLINED'; reason: string };

async function checkout(): Promise<Result<Order, CheckoutError>> {
  // ...
}

在 UI 层,同样可以用可辨识联合描述页面状态,避免多个布尔值互相矛盾:

view-state.ts
type ViewState<T> =
  | { status: 'loading' }
  | { status: 'ready'; data: T }
  | { status: 'error'; message: string };

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function renderView(state: ViewState<User>) {
  switch (state.status) {
    case 'loading': return '加载中';
    case 'ready': return state.data.name;
    case 'error': return state.message;
    default: return assertNever(state);
  }
}

当未来新增一种状态而忘记处理时,assertNever 会让编译失败。这种“修改一处,类型系统提示所有需要同步的地方”的能力,比更复杂的泛型技巧更有日常价值。

至于真正不可预期的错误——网络中断、程序缺陷、第三方崩溃——仍然应该抛出并交给全局错误边界。关键在于区分:业务预期内的失败是状态,业务预期外的故障是异常。

类型是沟通工具,不是终点

回到最初的问题:TypeScript 让代码更安全了吗?它让一部分错误更早被发现,也让接口意图更清楚。但运行时的数据仍然需要被验证,边界仍然需要被设计,失败仍然需要被表达。

所以我现在更常问三个问题:

  1. 这个类型在边界处有运行时证据吗?
  2. 它是在帮助读者理解契约,还是只在展示类型技巧?
  3. 可能失败的地方,调用者能否从类型中看见?

当这三件事做好,TypeScript 才不只是编辑器里的提示,而是团队共同维护的一套可靠约定。

沈健健

前端工程师,关注界面工程、性能与设计系统。相信好的技术判断,最终都会反映在用户不必思考的细节里。