
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 隐式控制流异常是一根看不见的 returntry/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 ResultT, E | { ok: true; value: T } | { ok: false; error: E };一个函数返回ResultUser, ApiError阅读函数签名的人立刻就能知道这个函数的结果有两种可能——成功拿到User或者得到ApiError。类型系统强制你看一眼调用点的处理逻辑你没法假装失败不存在。这个设计最大的价值在于“显式”。Go 语言之所以一直被大家讨论“错误处理太啰嗦”核心原因就是它的多返回值实际上就是一种 Result 模式的简化版——value, err : doSomething()。在 Go 里你不会忘记检查错误因为编译器虽然不强制但代码规范和人的人口经验都在反复提醒你err ! nil是必须处理的。Rust 更是把 Result 变成了语言级的标配std::result::Result无处不在。2.2 状态设计三个字段的 Result 箱我们在工程落地的时候不一定要用抽象类型一个简单的类或者接口就够了。我自己常用的版本是这样一个三字段结构class ResultT { 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 okT(value: T): ResultT { return new ResultT(true, value, undefined); } static failT(error: Error): ResultT { return new ResultT(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 ResultT, E Error | { ok: true; value: T } | { ok: false; error: E }; function getUser(id: string): ResultUser, 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 ResultT { static okU(value: U): ResultU { ... } static failU(error: Error): ResultU { ... } 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 ResultT { public bool IsSuccess { get; init; } public T? Value { get; init; } public string? ErrorMessage { get; init; } public static ResultT Success(T value) new() { IsSuccess true, Value value }; public static ResultT Failure(string message) new() { IsSuccess false, ErrorMessage message }; } // 使用 public Resultstring GetConfig() { if (config null) { return Resultstring.Failure(配置未初始化); } return Resultstring.Success(config.Name); } var r GetConfig(); if (!r.IsSuccess) { _logger.LogWarning(获取配置失败{Error}, r.ErrorMessage); return Resultstring.Failure(r.ErrorMessage); }C# 这边有个小坑record的相等比较是值语义如果你在集合里使用 Result 容器要注意 null 处理和泛型约束。另外 C# 里常见做法是给 Result 加上一个隐式转换让业务代码更简洁但隐式转换同时会牺牲显式性我一般不推荐在团队里用统一显式调用更好维护。3.3 让调用方“被迫”处理错误很多同事问我如果我的调用方不检查isSuccess直接操作value怎么办这个问题在静态类型的语言里可以靠编译器拦截一部分但更关键的是约定。我的做法是在代码规范里直接禁止对Result.value的无条件访问。所有对 Result 的处理都必须先判断状态。为了把这条规则固化下来可以在类里把value的公开 getter 去掉只留unwrapOr(defaultValue)方法这样调用方就必须显式提供一个兜底值。class ResultT { private constructor(private readonly _value?: T, private readonly _error?: Error) {} static okU(value: U): ResultU { return new ResultU(value, undefined); } static failU(error: Error): ResultU { return new ResultU(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 吞异常”的代码。还有一个有用的策略是“包装适配器”。比如某个旧方法还是抛异常你可以不动它只在调用方外面包一层把异常转成 Resultfunction callWithResultT(fn: () T): ResultT { 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 做一次变换可以封装mapclass ResultT { mapU(fn: (value: T) U): ResultU { if (!this.isSuccess) return this as unknown as ResultU; const mapped fn(this._value!); return Result.ok(mapped); } } // 使用 const displayName getUser(id).map(u u.username).unwrapOr(游客);第二合理嵌套。如果你的调用链里每一步都可能失败可以把多个 Result 串在一起只要有一个失败就整体失败这其实就是函数式编程里的 bind 或者 flatMap。写出来的代码可读性比一层层 if 好得多。function flatMapU(fn: (value: T) ResultU): ResultU { 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。