你的函数签名说它返回一个 User。事实并非如此。它返回一个 User,或者它会爆炸。类型系统根本不知道还有第二条分支。
这正是基于异常的错误处理最根本的不诚实之处。每一个 throw 都是一条编译器看不见、检查不了、也无法强制执行的控制流路径。最终你会得到这样的代码:它完美通过了类型检查,却仍然会在生产环境中崩溃,只因为有人在三层调用栈之外漏写了一个 try/catch。
显式错误返回值修复了这一点。它让失败成为类型系统中的一等公民。问题在于,为了这份诚实,你愿意付出多少人体工程学上的代价。
类型级正确性对错误意味着什么
类型级正确性意味着你的类型如实反映一个函数可能产生的结果。如果一个函数可能失败,返回类型就应该明确说明。如果不可能失败,返回类型就应该保证成功。
Rust 用 Result<T, E> 来编码这一点:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
这个签名是完备的。调用者知道有两种结果。编译器强制他们必须处理这两种情况。
TypeScript 默认则不然。一个会抛异常的函数和一个不会的,看起来完全一样:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
返回类型写的是 number。它本应该写成 number | Error,或者更准确地说,Result<number, Error>。但它没有,所以调用者收不到任何信号,提醒他们需要防御失败。
抛出的错误如何在你的调用图中隐藏
随着调用栈加深,损害会层层叠加。一个叶函数抛出的错误会迫使每一层中间代码要么捕获它,要么隐式传播它。而这些传播路径不会出现在任何类型签名中。
想象一个配置加载器调用解析器,解析器再调用验证器。如果验证器抛异常,解析器和加载器也都会变成可能抛异常的对象。但它们的类型不会变。你无法看一眼 loadConfig() 就知道它可能会失败。你得逐行读完所有依赖,或者等着运行时崩溃。
这与类型的设计初衷完全背道而驰。类型存在的意义,就是让你不必阅读实现细节就能理解契约。
显式错误返回值扭转了这一局面。失败路径在每一层都清晰可见:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
现在 loadConfig 的签名已经说明了全部。还没读函数体的一行代码,你就知道它可能会失败。
真正的权衡是人体工程学,而非表达力
这种诚实的方式更冗长。没人喜欢把 throw new Error(...) 写成 { ok: false, error: ... }。视觉噪音会累积,尤其是在串联多个可能失败的操作时。
这就是为什么大多数支持受检异常的语言最终都放弃了它们。Java 的受检异常试图在类型层面强制显式处理,但语法过于严苛。Rust 的 ? 操作符以及 map、and_then 这类 Result 组合子,把人体工程学挽回了一大半。TypeScript 没有 ? 操作符,但你可以借助辅助函数接近同样的体验:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
它仍然比 Rust 笨重。如果你在写 TypeScript,neverthrow 这样的库提供了带有链式方法的 Result 类型,用起来几乎像原生语法。开销确实存在,但一旦有了辅助函数,它比你想象的要小。
还有另一项代价。抛出的错误会自动生成丰富的堆栈跟踪。返回的错误只是普通值。如果你想要堆栈跟踪,就得自己构造。对于调试来说,这很重要。对于业务逻辑中可预期的错误处理,通常并不重要。
什么时候抛异常仍然合理
我们并不是主张零异常。有些失败确实是真正的异常情况,不该纳入你的常规错误模型。加载配置时文件缺失属于预期内的失败。内存溢出则不是。
经验法则是:如果失败属于你的业务领域,就返回它。如果是程序缺陷或灾难性的系统故障,就抛出它。JSON.parse 在输入格式错误时抛异常很烦人,因为无效 JSON 很常见。Array.prototype.map 在 null 数组上抛异常则没问题,因为对 null 调用 .map 本身就是 bug。
在实践中,这意味着你的 I/O 边界、解析器、验证器和业务规则应该返回错误。你的不变量、断言以及真正灾难性的情况仍然可以抛异常。
从你的 API 边界开始
你不需要重构整个代码库。采用显式错误返回值收益最高的地方,是你的公共 API 和 I/O 层。这些边界正是调用者最需要知道哪里会出错的。
挑一个模块。把它的返回类型改成 Result<T, E>。更新它的调用方。感受一下。这种模式有一种特殊的上瘾性:一旦你不再为了找漏掉的 try/catch 而翻遍堆栈跟踪,你就会意识到抛异常曾经消耗了你多少心智负担。
错误的类型级正确性不是为了写出完美的代码。而是为了写出更少说谎的代码。