TypeScript 最容易被误解的一点,是它能让 JavaScript “变安全”。更准确的说法是:它让一部分错误提前显形,让意图在编译期被检查。但当数据从 API、URL、localStorage 或用户输入进入应用时,类型系统并不知道运行时真正发生了什么。
写得越久,我越少关注复杂类型技巧,而更在意三件更基础的事:边界是否可信、推导是否清晰、错误是否被正确表达。这三件事决定了类型系统是在帮助思考,还是只是在提供安全感。
第一件事:类型边界必须与运行时边界对齐
下面的代码在 TypeScript 看来完全合法:
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 库;重点不是选哪个工具,而是让信任有一个明确的产生点。
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,这些是别人依赖的契约,值得明确表达。内部变量、临时对象、实现细节,如果可以从上下文稳定推导,就不需要再写一遍。
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,会让调用者难以知道究竟可能发生什么。对于业务上可预期的情况,例如校验失败、权限不足、库存为零,我更愿意把它们放进返回值里,让类型系统提醒每一个调用点。
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 层,同样可以用可辨识联合描述页面状态,避免多个布尔值互相矛盾:
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 让代码更安全了吗?它让一部分错误更早被发现,也让接口意图更清楚。但运行时的数据仍然需要被验证,边界仍然需要被设计,失败仍然需要被表达。
所以我现在更常问三个问题:
- 这个类型在边界处有运行时证据吗?
- 它是在帮助读者理解契约,还是只在展示类型技巧?
- 可能失败的地方,调用者能否从类型中看见?
当这三件事做好,TypeScript 才不只是编辑器里的提示,而是团队共同维护的一套可靠约定。