1. 先把问题掰开:try/catch 到底错在哪
这个项目的代号有点意思,“[]try catch no | result yes”,翻译过来就是“我不要 try/catch,我要返回结果”。如果你也在一线写过几年代码,看到这句话大概会心一笑——我们都经历过那个被异常处理折磨到怀疑人生的阶段。
先说个我自己的真实经历。某天晚上线上告警突然响了一排,一个核心查询接口的返回耗时从 50ms 飙到 3s,但诡异的是错误率一条都没涨。排查了两个小时,最后发现是底层一个 RPC 调用在某个边界条件下抛了超时异常,被中间层的catch (Exception e) { log(e); return null; }悄悄吞掉,上层拿到 null 之后走了一段毫无意义的重试逻辑,白白等了几轮超时。异常被吞了,错误率是零,但是接口变慢了 60 倍。那行 catch 是谁写的?是我自己三个月前写的。
从那之后我开始认真思考一个问题:用 try/catch 做错误处理,到底是不是一个值得信赖的默认选项?
这篇内容我想把这件事讲透。我会先拆解 try/catch 的核心缺陷,然后讲清楚 Result 模式(也就是把错误当作返回值来处理的一种模式)为什么能解决这些问题,再给出 TypeScript、C#、Go 这类语言里可直接落地的实现方案,最后附上我在真实项目里迁移改造时踩过的坑和排查清单。适合谁看?写业务代码写到一半觉得异常处理很别扭的人,团队里想统一错误处理规范的技术负责人,以及准备从“异常驱动”切到“返回值驱动”的折腾型选手。
1.1 隐式控制流:异常是一根看不见的 return
try/catch 最大的问题不在于它“能做什么”,而在于它“看不见”。当你调用一个可能抛异常的函数时,它的返回路径是分叉的:一条是正常的return,另一条是异常对象沿着调用栈一路往上抛。这个第二路径完全不会出现在函数签名里。
我经常用一个类比来说明这件事:正常的 return 像是你坐地铁回家,每一站都明明白白写在线路图上;异常则像是你突然被传送到了一个完全陌生的地方,传送门到处都是,但你抬头看不到任何路标。你只知道“可能被传送走”,但不知道会被传到哪一层。
在小型项目里,这种隐式控制流问题不大,因为调用链短、人的脑子还装得下。但一旦系统到了十几个微服务、四五个调用层级的规模,异常就变成了一个巨大的不确定性来源——A 服务抛的异常可能在 B 服务的某个中间层被捕获,也可能一路穿透到最外层被兜底拦截,你根本没办法从代码结构上判断当前这一行调用到底有多少种“隐形出口”。
更麻烦的是,多数语言里你可以不声明“此函数会抛异常”。以 TypeScript 为例,一个函数抛了运行时异常,编译器不会给你任何提醒。写代码的人以为自己在调用一个“纯函数”,实际上是在调用一个可能随时引爆的炸弹。这种不确定性和隐式性,是 try/catch 被误解、被滥用、被潜在 bug 埋雷的根源。
1.2 吞异常:catch 块是 bug 的最佳藏身地
catch { /* 什么都不做 */ }可能是整个软件行业里产生最多线上故障的代码模式。吞异常偶尔是合理的——某些场景你确实不关心失败原因,比如写日志失败不能影响主流程。但现实是,绝大多数吞异常都是因为“我不知道怎么处理它”,而不是“我明确知道这里失败没关系”。
一旦选择了吞,就默认了一个假设:这个错误可以被忽略。可是等到真正出问题的时候,你会发现被吞掉的那个异常恰好就是定位问题的唯一线索。“我已经 log 了呀”是另一句常见辩护,但是日志系统本身会丢消息、会被清理、会在高并发下截断,更常见的是你 log 完之后根本没人去看。
我见过一个比较典型的场景:某人的代码里把所有底层错误统一 catch 成一条log.error("调用失败", e),然后抛出一个新的业务异常throw new BizException("处理失败")。表面上看没有吞,实际上原始的错误类型、错误码、错误堆栈全被抹平了。到排查问题的时候,你只能看到一个笼统的“处理失败”,而真正有价值的信息早就被转换过程丢掉了。这就是异常链断裂问题——每一层 catch 都是一次信息衰减。
1.3 契约缺失:方法签名不告诉你“我会抛什么”
我们再往深一层看。try/catch 不只是在运行时不可见,在设计层面它也没有形成一种“契约”。所谓契约,就是调用方能够从方法签名里明确知道:这个函数会成功返回什么,会失败返回什么,失败有哪些可能。
Java 的 checked exception 曾经想解决这个问题,它强制你在方法签名里声明throws,强制调用方处理。但是工程实践告诉我们,大批开发者用throws Exception或者把异常包一层再抛出来的方式绕过了这个约束。最终 checked exception 在很多团队里形同虚设。到了 C#、JavaScript、Go 这些语言,异常更是赤裸裸地不受约束。
结果就是:调用方对“这个函数可能会失败”这件事完全没有预期。你写const user = await getUser(id);然后下一行就开始用user.name,一旦getUser内部出问题,你只能等一个幽灵般的异常从某个角落冒出来。这种代码是“乐观编程”,默认一切成功,把失败当作小概率的突发事故来处理。而真实世界的分布式系统里,失败是常态,不是事故。
Result 模式要解决的,就是这个“契约缺失”的问题。
2. Result 模式的设计核心:把错误抬到明面上
既然异常处理有问题,那自然有人会想:能不能把“失败”也当成一种正常的返回值?这就是 Result 模式的核心思想。它不依赖调用栈的隐式传播,而是把成功与失败编码成一个显式的值,让调用方“看得见、绕不过”。
2.1 类型签名:把错误变成返回值
简单来说,Result 模式就是一个带有“成功”和“失败”两个分支的容器类型。它最经典的形态是这样的:
type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E };一个函数返回Result<User, ApiError>,阅读函数签名的人立刻就能知道:这个函数的结果有两种可能——成功拿到User,或者得到ApiError。类型系统强制你看一眼调用点的处理逻辑,你没法假装失败不存在。
这个设计最大的价值在于“显式”。Go 语言之所以一直被大家讨论“错误处理太啰嗦”,核心原因就是它的多返回值实际上就是一种 Result 模式的简化版——value, err := doSomething()。在 Go 里你不会忘记检查错误,因为编译器虽然不强制,但代码规范和人的人口经验都在反复提醒你:err != nil是必须处理的。Rust 更是把 Result 变成了语言级的标配,std::result::Result无处不在。
2.2 状态设计:三个字段的 Result 箱
我们在工程落地的时候不一定要用抽象类型,一个简单的类或者接口就够了。我自己常用的版本是这样一个三字段结构:
class Result<T> { readonly isSuccess: boolean; readonly value?: T; readonly error?: Error; private constructor(isSuccess: boolean, value?: T, error?: Error) { this.isSuccess = isSuccess; this.value = value; this.error = error; } static ok<T>(value: T): Result<T> { return new Result<T>(true, value, undefined); } static fail<T>(error: Error): Result<T> { return new Result<T>(false, undefined, error); } }这三个字段的意义是:isSuccess告诉你这次调用的结果状态,value在成功时承载业务数据,error在失败时承载错误对象。使用方拿到一个返回值之后,可以先判isSuccess,再决定从value里取数据还是从error里取失败原因。
有人会问,为什么不直接 return null 表示失败?其实 null 的问题恰恰是它什么信息都不携带——你不知道为什么失败,是参数非法还是下游超时?而 Result 的 error 字段可以携带 Error 对象、错误码、上下文信息,比裸 null 强得多。在 Rust 里你甚至可以定义自己的错误枚举类型,理论上每个错误分支都清晰可枚举。
2.3 为什么说“异常只用于异常情况”
采用 Result 模式不代表要消灭所有异常。我的实践原则是:在业务代码的主流程里,错误用返回值传递;异常只留给那些真正“异常”的情况——比如编程错误、资源耗尽、不可恢复的系统崩溃。
业务逻辑里的错误,本质上是“预期中的分支”——用户传了非法参数、库存不足、余额不够,这些不是异常,这是业务本身的正常分支。你完全可以通过返回值把它们表达出来。而 OutOfMemoryError、StackOverflowError、数据库连不上但连接池又没配超时之类的情况,才值得用异常机制去兜底。
这个划分让我想起一个很好的类比:异常机制像是火警铃,平时不该响,响了就是大事;Result 模式像是电梯里的楼层按钮,每个楼层都是一种可选结果,你按哪个就亮哪个,一切尽在掌握。你把“预期中的小概率”全部交给火警铃,火警铃天天误报,最终真正出事的时候没人重视,这才是最危险的。
3. 多语言落地:怎么把 Result 搬进你的代码
思路清楚之后,最难的是落地。不同的语言生态对 Result 模式的支持度不一样,我把几个主流语言的实际写法都拆开讲讲,方便你直接复制到项目里改。
3.1 TypeScript / JavaScript 的 Result 实现
TypeScript 的代数类型比较灵活,可以用 union 或者 class 两种风格。如果团队比较偏函数式,推荐 union 风格:
type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E }; function getUser(id: string): Result<User, Error> { try { const raw = await api.get(`/users/${id}`); return { ok: true, value: raw.data }; } catch (e) { return { ok: false, error: e as Error }; } } // 调用方 const result = await getUser("123"); if (!result.ok) { console.error("加载用户失败:", result.error); return; } console.log(result.value.name);这种方式的好处是类型推导很自然,value和error会根据ok字段自动收窄。JS 运行时层面其实没有额外的容器开销,只是一个普通对象而已。坏处是限制不强——如果你写的是纯 JavaScript,没有人能拦住你直接访问result.value,哪怕它现在是 undefined。
类风格的优势是结构更清晰,可以挂一些工具方法,比如map、mapError、unwrapOr。我建议中大型项目里封装成类,把样板逻辑收敛进去。
class Result<T> { static ok<U>(value: U): Result<U> { ... } static fail<U>(error: Error): Result<U> { ... } isOk(): boolean { return this.error === undefined; } unwrap(): T { if (this.error) throw this.error; return this.value!; } unwrapOr(defaultValue: T): T { if (this.error) return defaultValue; return this.value!; } }3.2 C# 的 Result 实现
C# 的生态里 ErrorOr、OneOf 这样的库已经比较成熟,但自己封一个也很简单。我的推荐是结合 C# 9 的 record 类型:
public record Result<T> { public bool IsSuccess { get; init; } public T? Value { get; init; } public string? ErrorMessage { get; init; } public static Result<T> Success(T value) => new() { IsSuccess = true, Value = value }; public static Result<T> Failure(string message) => new() { IsSuccess = false, ErrorMessage = message }; } // 使用 public Result<string> GetConfig() { if (config == null) { return Result<string>.Failure("配置未初始化"); } return Result<string>.Success(config.Name); } var r = GetConfig(); if (!r.IsSuccess) { _logger.LogWarning("获取配置失败:{Error}", r.ErrorMessage); return Result<string>.Failure(r.ErrorMessage); }C# 这边有个小坑:record的相等比较是值语义,如果你在集合里使用 Result 容器,要注意 null 处理和泛型约束。另外 C# 里常见做法是给 Result 加上一个隐式转换,让业务代码更简洁,但隐式转换同时会牺牲显式性,我一般不推荐在团队里用,统一显式调用更好维护。
3.3 让调用方“被迫”处理错误
很多同事问我:如果我的调用方不检查isSuccess,直接操作value怎么办?这个问题在静态类型的语言里可以靠编译器拦截一部分,但更关键的是约定。
我的做法是:在代码规范里直接禁止对Result.value的无条件访问。所有对 Result 的处理都必须先判断状态。为了把这条规则固化下来,可以在类里把value的公开 getter 去掉,只留unwrapOr(defaultValue)方法,这样调用方就必须显式提供一个兜底值。
class Result<T> { private constructor(private readonly _value?: T, private readonly _error?: Error) {} static ok<U>(value: U): Result<U> { return new Result<U>(value, undefined); } static fail<U>(error: Error): Result<U> { return new Result<U>(undefined, error); } get value(): T { if (this._value === undefined) { throw new Error("Cannot access value from a failed Result"); } return this._value; } get error(): Error { if (this._error === undefined) { throw new Error("Cannot access error from a successful Result"); } return this._error; } }这是“够着够着用”的办法:通过让容器本身提供访问控制,让错误状态是“可发现”的。不过坦白讲,真正的约束还是靠人在 review 的时候盯住——这一点没有任何语法糖能替代。
4. 实操迁移:把老代码一步步改造成 Result 模式
理念讲完,最难的是动手。我经历过的真实改造,从一个日请求量千万级的核心服务开始,前后花了大概两周晚上加周末的时间。这里把步骤和教训完整记录下来。
4.1 改造前的盘点
第一步不是写代码,而是盘点。
我把自己负责的一个核心模块的所有对外方法列了一张表,看每个方法的错误处理方式。分类大概是这么几种:
- 直接抛异常,调用方不管;
- 方法内部 catch 了直接返回 null;
- 方法内部 catch 了返回一个默认对象;
- 方法内部 catch 了,但是重新抛了一个自定义异常;
- 方法签名上标注了可能抛异常,但调用方只是 try 了一下然后什么都不写。
从这张表里能看出来,改造工作量最大的是“异常在中间层被吞掉”和“返回值直接给 null”这两类。前者逻辑本身就是错的,得先修正再迁移;后者相对好迁移,只需要把 null 改成Result.fail即可。
4.2 改造顺序:自底向上还是自顶向下
这个问题我纠结了很久。一开始我倾向于自底向上——先把底层 RPC 调用全部改成返回 Result,因为错误源头在底层。但做完第三天就发现阻力巨大:底层的调用方太多了,每个调用方都要跟着改,一次改动涉及几个模块,根本没法在两周内完成。
后来我换成了“先上层、后底层、中间层适配”的顺序。具体操作是:先让最上层(对外接口层)的输出改成 Result 结构,上游接口返回给前端时统一是{ code, msg, data },这个天然和 Result 对得上。然后往下推,每一层在向上返回时,如果底层返回失败,就直接把失败透传上去。
这个顺序的好处是:每一层改造时,对外可见的接口协议已经先固定了,改代码的人不需要担心破坏下游的调用。改到哪一层哪一侧是自发驱动的,不是被强迫的。缺点是要在中间层写一些兼容代码,但这些兼容代码的生命周期一般是临时的,等所有层都改完再删掉即可。
4.3 过渡期的兼容策略
改造过程中,最讨厌的是新旧代码混在一起。同一份数据,A 接口是 Result 返回,B 接口还是抛异常,上层代码需要两套分支。我建议一个土办法:新写的代码一律 Result 模式;旧代码改造按照“线上出过异常问题的优先、改动量最小的优先、调用方少的优先”排序。并且明确一个期限,比如一到两个月内,禁止再新增任何“纯 try/catch 吞异常”的代码。
还有一个有用的策略是“包装适配器”。比如某个旧方法还是抛异常,你可以不动它,只在调用方外面包一层,把异常转成 Result:
function callWithResult<T>(fn: () => T): Result<T> { try { return Result.ok(fn()); } catch (e) { return Result.fail(e as Error); } }这算是一个战略缓冲:短时间内你不需要把整个调用链全部推倒重来,你只需要在每个入口处套一层这个函数,让调用方被动接受 Result 的处理方式。等后续有大版本迭代的时候,再顺着入口往里逐层渗透。
4.4 一个关键教训:不要用 Result 处理所有错误
我必须泼一盆冷水:Result 模式不是万能药,工程上最忌讳“一刀切”。
有些地方用 Result 反而会让代码更糟糕。比如一个工具方法,formatTime这种,它的错误概率极低,失败原因也无关紧要,你硬改成 Result 只会让每个调用点多一堆 if 判断。这种地方我认为该抛异常就抛异常,或者更干脆,让它在边界条件内保证不失败,比如参数校验前置,而不是靠异常兜底。
还有一个反例是异步事件处理。在事件消费、消息队列处理这种场景里,如果一个消息处理失败,你通常希望它走“重试队列”而不是“返回结果”,这种时候 Result 的返回值就显得无力,因为它不携带“该不该重试、重试多少次”的策略信息。这种场景更适合设计专门的错误元数据,而不是套一个通用 Result。
判断标准其实很简单:你的调用方需不需要根据失败原因做不同的分支处理?需要,就用 Result;不需要,且失败概率低,那异常或断言语法也都行。
5. 常见问题与排查技巧实录
下面是我在实操中被问得最多的几个问题,也是我踩过坑之后才找到答案的地方。
5.1 catch 到一半发现是 Result,怎么办
这个问题出现的频率高到离谱:同事把旧代码改成 Result 模式之后,从底层传上来的已经是 Result 了,但在某个中间层还有人用 try/catch 包裹,结果Result.ok(...)被 catch 吞掉,或者.value访问的时候抛了一个新的异常,把原来的错误信息盖住了。
我的排查经验是:在中间层千万不要大包大揽地 catch。改造期间,你能做的只有两件事——要么把 Result 透传上去,要么在确认“这个错误需要转换语义”的时候才 catch 之后重新封装。至于那些“catch 了想打个日志继续跑”的逻辑,基本都是吞异常的前奏,建议看到一条删一条。
5.2 太啰嗦了:怎么压缩样板代码
Result 模式被吐槽最多的问题就是“每个方法都要写 if”,加了很多代码。我自己经历过两个阶段的优化:
- 第一,带
map方法。如果你只是想在成功时对 value 做一次变换,可以封装map:
class Result<T> { map<U>(fn: (value: T) => U): Result<U> { if (!this.isSuccess) return this as unknown as Result<U>; const mapped = fn(this._value!); return Result.ok(mapped); } } // 使用 const displayName = getUser(id).map(u => u.username).unwrapOr("游客");- 第二,合理嵌套。如果你的调用链里每一步都可能失败,可以把多个 Result 串在一起,只要有一个失败就整体失败,这其实就是函数式编程里的 bind 或者 flatMap。写出来的代码可读性比一层层 if 好得多。
function flatMap<U>(fn: (value: T) => Result<U>): Result<U> { if (!this.isSuccess) return Result.fail(this._error!); return fn(this._value!); }当然,typeScript 里也可以配合 Promise 做一些 try/catch 的自动包装,但那一套经常让你失去对错误类型的分辨能力,我建议还是保持最朴素的显式写法。
5.3 性能顾虑:Result 会不会比异常慢
这个问题通常来自 Java 或 C# 背景的同学,他们知道异常在高频场景下是有性能代价的——异常栈的填充很贵。那 Result 模式呢?
严格说,创建一个对象也需要内存分配,但比异常栈轻量太多了。异常对象的创建需要抓取堆栈、填充调用栈、保存线程快照,这在很多语言里都是昂贵的操作。而 Result 只是一个普通对象,成本很小。
我对性能的判断是:如果你的异常是在“真正的异常情况”下才抛出的——比如一个月才触发一次——那性能差异可以忽略不计。反之,如果你的业务逻辑里异常被用作常规控制流,比如用异常来处理参数校验失败,那这个模式下异常性能会非常难看。Result 模式天然鼓励你“把常规失败当作返回值”,所以它的性能特征是友好的。我在压力测试里对比过单机每秒处理十万次请求的场景,改用 Result 后吞吐几乎没有下降,反而因为少了大量异常对象的创建,GC 压力小了一些。
5.4 一个容易被忽视的坑:第三方库的异常吞不掉
Result 模式的另一个限制是它只能管住你自己的代码。第三方 SDK 的异常你没办法让它返回 Result,还得靠 try/catch 包一层。这时候我建议在边界处做转换,也就是只在“防腐层”使用 catch,把第三方异常转换成我们自己的错误类型,而不是让它顺着业务代码一路铺开。
比如你在调用 HTTP 客户端,先在最外层包一个SafeHttpClient,把所有请求方法封装成返回 Result 的形式,业务代码永远不直接触碰原始的请求库。这样第三方库的波动被隔离在防腐层内部,业务代码始终是 Result 驱动的。
6. 写在最后的实践心得
代码写着写着就会认识到一件事:错误处理不是“可选的健壮性”,而是“代码可读性和可维护性”的核心组成部分。try/catch 本身不是一个错误,它是一种工具,只是它作为默认选项在绝大多数业务系统里被用错了方式。Result 模式没有魔法,它只是换了一种更显式的思路,让失败的路径变得清晰可见。
从个人感受看,改用 Result 之后,最大的变化不是 bug 数量变少(当然真的会变少),而是 code review 的速度快了很多。以前看到 try/catch 我要一行一行看 catch 里到底做了什么,现在看到一个返回 Result 的方法,只要扫一眼它的调用方分支处理逻辑就够了。那种“盯着 log 找异常”的夜班经历,再也没有频繁出现了。
还有一个小技巧值得分享:如果你刚开始推行 Result 模式,不必追求一次性把项目里所有代码都改完。先挑一个最常出错的模块,把它的错误处理全部收敛成 Result,跑通一个小闭环,让团队里看到实际效果。有了样例之后,其他人自然就会跟着用了。我当年就是靠两个小模块打样,后来整个团队慢慢统一了风格,最后连新同学入职第一课都是“这里不用抛异常,用 Result.fail 就行”。
代码风格这件事,最怕的是每个人都按自己的理解写。只要全队能在一套统一的错误处理策略下协作,无论你选 try/catch 还是 Result,都比“一半人抛异常、一半人返回 null”要好得多。而如果你已经受够了那种“不知道错误从哪来、也没人处理错误”的代码,那不妨从今晚开始,把你下一个函数改成返回 Result。