
刚写代码那几年我对错误处理一直有种说不出的别扭。函数要么返回一个正常值要么返回一个约定好的特殊值比如-1、null、404调用方收到以后再用 if 猜一下这是什么情况。运气不好还会在某个深夜被线上异常吵醒因为你漏掉了一个 catch。后来我在重构一套老接口时开始认真使用 result 类才逐渐理解了那些看着“多一层包装”的写法到底在解决什么问题。这篇博客我想把理解到的、踩过的坑一起捋一遍给还在犹豫要不要在自己的项目里引入 result 类的朋友们做一个参考。result 类通俗地说就是把“成功”和“失败”两种状态装进同一个返回值里由类型系统和业务代码一起显式处理。它不是一个花哨的新概念而是对不同语言错误处理方式的一种总结和规范化。适合谁看刚工作两三年的后端、前端同学以及正在为“到底用异常还是用返回值”纠结的团队都能从这里找到一些启发。1. 为什么需要 result 类——从传统错误处理的痛点说起1.1 过去我们怎么处理“失败”先聊聊我在没有 result 类时常用的几种方案。第一种是魔法数字。比如定义一个findUser(id)函数用户不存在时返回-1。代码写起来很快但问题不少如果返回值本身可能是负数那调用方根本分不清这个-1是正常业务值还是错误码如果后来又加了-2表示数据库异常、-3表示参数非法你就要在文档里维护一长串数字表漏掉任何一个都是隐患。第二种是返回null或undefined。这个比魔法数字语义清楚一点但也有致命伤null究竟表示“没有找到”还是“查询失败”如果你是一个刚接手这段代码的开发你会直觉地以为null代表“空结果”然后自然地把它当作正常分支去处理。等到某个字段最终在中途被调用你才拿到空指针异常。更麻烦的是编译器并不会强制你判断null漏掉判断纯靠自觉。第三种就是异常。异常是我们最熟悉的错误处理方式但它的最大问题是“隐式”的。函数签名里没有明确标注它会抛出什么异常调用方如果不看实现很可能完全不知道这里需要 try。异常还会打断正常的控制流在大规模异步、并发场景下如果不小心捕获错误地处理了异常往往会把业务状态搅得一团糟。另外异常在性能上也不是免费的很多语言中构造异常对象、抓取堆栈、展开调用栈的成本都不低如果拿它处理高频的业务上预期错误就是一种很大的浪费。这三种方案在单一场景里可能都够用但它们有一个共同弱点错误处理完全依赖调用方自觉自动或编译器的约束几乎为零。代码一旦多了调用关系一复杂“到底哪里可能失败、失败以后怎么处理”这个问题就会变成一个没人说得清的谜。1.2 Result 类的核心设计目标把“成败”本身变成值result 类要解决的核心问题就是把“这次调用到底成没成功”这件事变成函数返回值的一部分而不是藏在一个隐式的抛出机制或容易混淆的null里。你可以把 result 想象成一个信封信封里只能装两种东西中的一种要么是成功的结果Ok要么是失败的错误信息Err。调用函数时你拿到的永远是这样一个“双重可能”的信封你必须在打开之前先检查它到底装着哪一边。这样一来错误不再是调用方忘记处理就会被忽略的角落而是类型系统里显式存在、无法轻易绕过的一个分支。这有点像寄快递的签收单快递员送货上门时你必须在“签收”和“拒收/异常”之间做一个选择没办法假装没看到。传统错误处理里异常仿佛是快递员把包裹直接扔到阳台还不告诉你一声null则像是空恒温箱压根不知道里面有没有东西而 result 类就是一张清清楚楚的签收单让你知道这个流程一定会给你一个明确答复。在不少强类型语言里result 类还带了编译期检查。等你把ResultT, E写进函数返回类型编译器就能在一定程度上帮你把关你忘了处理Err分支代码就是过不去。这份“被霸道的约束”恰恰是老旧错误处理方式最缺的东西。2. 核心细节解析与实操要点Result 类的接口设计2.1 组成一个最小可用的 ResultOk、Err、解包与匹配我见过很多团队自己造 result 类接口五花八门但核心其实就那么几个。先说最小可用集合。第一是类型定义。最清晰的写法是用一个联合类型而不是把所有字段硬塞到一个 class 里。以 TypeScript 为例type ResultT, E | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };这里的T是成功时的值E是失败时携带的错误信息。把ok字段设置成字面量布尔值是为了后面做类型收窄也能让编译器和编辑器推断出更精确的类型。第二是构造器。一般约定使用上层函数或静态方法比直接干巴巴地写对象字面量可读性更好function okT, E(value: T): ResultT, E { return { ok: true, value }; } function errT, E(error: E): ResultT, E { return { ok: false, error }; }第三是解包操作。所谓解包就是从 result 里把真正的结果拿出来。这一步必须暴露给调用方否则 result 只能当摆设。但解包也分“安全”和“危险”两种unwrap()成功时返回value失败时直接抛异常。适合测试环境和不预期出现错误的场景。unwrapOr(default)成功时返回value失败时返回你提供的默认值适合给调用方一个兜底方案。第四是模式匹配。这是我强烈建议任何实现都要带的方法因为它强制你同时处理成功和失败function matchT, E, R( result: ResultT, E, patterns: { ok: (value: T) R; err: (error: E) R; } ): R { return result.ok ? patterns.ok(result.value) : patterns.err(result.error); }这个match方法的好处是不管成功还是失败最终你都会得到一个确定类型的R业务代码不需要在函数外面再做 if/else 散落一地。2.2 组合能力map、flatMap、recover很少有人只用一个Result多数场景是把多个可能失败的操作像流水线一样串起来。这时候就需要三个关键的高阶方法。第一个是map它只对成功值做转换失败则原样传递。可以理解成给成功分支的包裹再套一层搬运带function mapT, E, U( result: ResultT, E, fn: (value: T) U ): ResultU, E { return result.ok ? ok(fn(result.value)) : result; }第二个是flatMap也叫andThen。当你的转换函数本身也可能返回Result时用map就会出现嵌套的ResultResultU, E, E这就很讨厌。flatMap会帮你把两层压扁成一层function flatMapT, E, U( result: ResultT, E, fn: (value: T) ResultU, E ): ResultU, E { return result.ok ? fn(result.value) : result; }第三个是mapErr和recover。mapErr能把底层错误转换成更上层的业务错误recover则能在失败时返回一个成功的替代值。比如const displayName match( findUser(id) .map((user) user.name) .mapErr((e) new BusinessError(用户查询失败, e)) .recover(() 匿名用户), { ok: (name) name, err: (e) e.message, } );把map、flatMap、recover组合起来业务逻辑会显得非常顺先取用户再取用户名转换错误类型最后兜底。整个过程没有一行 if 判断代码看着很干净而且每一步都可能失败的路径都被类型照顾到了。2.3 不可变性与纯函数让 Result 易测易维护我之前写 result 类时喜欢直接用 class 修改内部状态后来发现这是个大坑。result 类的价值之一是可预测如果外部代码能随意改value或error调试状态就会失控。更合理的实践是把 result 当作不可变对象每次map或flatMap都返回一个新的 result而不是修改当前对象。这么做的好处有几个。第一单元测试变得非常简单因为每个函数只需要接收一个Result并返回新的Result没有隐藏状态不需要 mock 复杂的上下文。第二调试时可以更放心地打印和检查中间每一步不用担心日志里的结果已经被别处改过。第三如果要并行处理多个 result尤其涉及多线程时不可变性天然是线程安全的。当然不可变也会带来轻微的性能开销比如每次转换都要创建新对象。不过在实际业务接口层这种开销通常可以忽略真正要杜绝的是滥用unwrap导致链式调用抛出一堆异常而不是对象本身创建带来的损耗。3. 实操过程与核心环节实现从零手写一个 Result 类3.1 用 TypeScript 写一个可在生产环境使用的 Result这里我展示一个可以直接拷贝到项目里、稍作裁剪就能用的实现。代码尽量精简保持类型完整既适合学习也适合当基础工具。// result.ts export type ResultT, E | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E }; export const Ok T, E(value: T): ResultT, E ({ ok: true, value }); export const Err T, E(error: E): ResultT, E ({ ok: false, error }); export function isOkT, E(r: ResultT, E): r is { ok: true; value: T } { return r.ok; } export function isErrT, E(r: ResultT, E): r is { ok: false; error: E } { return !r.ok; } export function unwrapT, E(r: ResultT, E, message?: string): T { if (r.ok) return r.value; throw new Error(message ?? Called unwrap on an Err: ${String(r.error)}); } export function unwrapOrT, E(r: ResultT, E, fallback: T): T { return r.ok ? r.value : fallback; } export function mapT, E, U( r: ResultT, E, fn: (value: T) U ): ResultU, E { return r.ok ? Ok(fn(r.value)) : r; } export function flatMapT, E, U( r: ResultT, E, fn: (value: T) ResultU, E ): ResultU, E { return r.ok ? fn(r.value) : r; } export function mapErrT, E, F( r: ResultT, E, fn: (error: E) F ): ResultT, F { return r.ok ? r : Err(fn(r.error)); } export function matchT, E, R( r: ResultT, E, patterns: { ok: (value: T) R; err: (error: E) R; } ): R { return r.ok ? patterns.ok(r.value) : patterns.err(r.error); } export function recoverT, E( r: ResultT, E, fallback: (error: E) T ): ResultT, E { return r.ok ? r : Ok(fallback(r.error)); }这段代码我实际用下来有几个注意点。用readonly保证不可变。虽然 TypeScript 的结构类型并不能严格控制运行时修改但在代码上已经明确约束了意图review 时大家也容易达成默契。把Ok和Err定义为函数而不是class执行起来更简洁类型推断也更好。如果你更喜欢 class 写法也可以只是要注意泛型在构造函数上的参数顺序。unwrap里提供一个可选的message参数调试时特别管用。很多团队在线上捞日志时看到“Called unwrap on an Err”这种大锅饭信息会崩溃带上上下文提示能帮你快速定位。3.2 各语言内置支持与第三方方案速览在不少现代语言里你不需要自己手写 result 类。下表是我平时用过的语言中常见的方案语言内置能力常用的库或补充方案核心操作RustResultT, E枚举 ?运算符无map,and_then,unwrap_or_else,?KotlinResultT标准库 runCatchingArrow 的Either更灵活getOrElse,map,recover,foldSwiftResultSuccess, Failure无map,flatMap,mapErrorTypeScript无内置neverthrow,oxide.ts,fp-ts的Eithermap,flatMap,matchJavaOptional只覆盖空值Vavr 的Try、Eithermap,flatMap,recoverCstd::expectedT, EC23tl::expectedvalue_or,and_then,transformRust 的?运算符是我个人觉得最舒服的错误传播方式失败分支不用写花哨的 match直接在函数末尾加个问号就能向上层抛错但这依赖语言级别的语法糖。Kotlin 的ResultT在标准库中出现得比较晚限制也不少比如不能直接存储它的协变类型在某些场景下会有点别扭所以很多人转投 Arrow 的Either。Swift 的Result设计很简洁但实际项目中不少团队更倾向于直接使用双枚举自定义不透明错误而不是徒手裸用泛型。TypeScript 没有内置 result 类这也是它成为社区大量库存在的原因。我用neverthrow较多它实现得比较全面有andThen、andTie、mapErr、match等操作还附带ResultAsync处理 Promise 场景。如果你不想引第三方依赖我在 3.1 里写那一段也够用。3.3 实际接入案例接口调用和表单校验只看基础用法不容易看出 result 类的价值我用一个常见的后端接口接入场景来演示。设计一个用户查询函数它可能因为网络、权限、记录不存在等原因失败type UserError | { kind: NOT_FOUND; message: string } | { kind: NETWORK_ERROR; message: string } | { kind: PERMISSION_DENIED; message: string }; function fetchUser(api: UserApi, id: string): PromiseResultUser, UserError { return api.get(/users/ id) .then(res res.ok ? Promise.resolve(Ok(res.data as User)) : Promise.resolve(Err(parseHttpError(res.status))) ) .catch(() Promise.resolve(Err({ kind: NETWORK_ERROR, message: 用户服务连接超时, }))); }在这个函数里调用方拿到的ResultUser, UserError会强迫它考虑失败分支。如果调用方是控制器可以直接用match生成不同的 HTTP 响应async function getUserHandler(req: Request, res: Response) { const result await fetchUser(userApi, req.params.id); match(result, { ok: (user) res.json(user), err: (e) { if (e.kind NOT_FOUND) res.status(404).json({ message: e.message }); else if (e.kind PERMISSION_DENIED) res.status(403).json({ message: e.message }); else res.status(503).json({ message: e.message }); }, }); }把失败分支集中在请求入口统一转换业务层不需要到处 try/catch测试时可以很轻松地 mock 成功或失败结果再断言输出。再举一个表单校验的场景。假设要校验用户名、邮箱、密码每项都可能失败传统做法可能写一堆 if 然后往errors数组里 push。用 result 类可以先把每个字段的校验结果变成Resultstring, ValidationError再用一个flatMap或Promise.all组合起来type ValidationError { field: string; message: string }; const nameResult validateName(input.name); const emailResult validateEmail(input.email); const passwordResult validatePassword(input.password); const allErrors [nameResult, emailResult, passwordResult] .filter(isErr) .map((r) (r as { ok: false; error: ValidationError }).error); if (allErrors.length 0) { return Err(allErrors); } return Ok({ name: unwrap(nameResult), email: unwrap(emailResult), password: unwrap(passwordResult), });这段代码看着比一堆 if/else 长了一点但它把成功值和错误路径清晰地分开了成功值在最后通过unwrap取出错误收集在allErrors里统一处理。后面加字段、加校验规则时增删代码都不会污染主干逻辑。4. 常见问题与排查技巧实录4.1 我踩过的几个坑都在说 result 类好不代表它没有坑。我自己在落地过程中踩过不少挑几个典型的讲。第一个坑是“假装在用 result实际上还在裸用异常”。有些同学把函数一律改成Result但一遇到异常分支就在业务代码里unwrap()结果就是把原来的隐式异常换成了更晚爆出的异常。解决办法很实在只在测试或绝对不该出现错误的边界用unwrap其它地方尽量用match或recover。可以在 lint 规则里禁止业务代码直接调unwrap只在测试目录放行。第二个坑是泛型类型参数不一致。多个 result 组合时如果Err类型不同mapErr就必须先统一错误类型。比如一个来自网络层的错误是NetworkError一个来自业务校验的错误是ValidationError你需要在合并之前把它们都转成AppError。这件事不能等到代码写一半再统一最好在项目起手时就约定一个公共错误基类或联合类型。第三个坑是嵌套 result 地狱。如果两个可能失败的操作都需要用另一个错误来解析不想动时你会发现代码里出现了ResultResult..的怪物。解决方式就是勤用flatMap或先拆出独立函数用中间变量分层包装别让回调叠三层。第四个坑是错误堆栈信息丢失。异常的天然优势是可以自动携带调用栈result 类则需要你手动在错误构造时记录上下文。我的经验是不要只丢一个字符串进去用一个对象保存kind、message、context必要时把原始堆栈也存进去。这样线上排错时才能从错误里看到更完整的脉络。还有一个坑是团队不熟悉带来的误读有人看到ResultUser, UserError会以为是“查询结果和错误列表”的二元组。这里需要文档和代码注释配合告诉同事这是二选一的封装不是“返回多个值”。4.2 什么时候用 Result什么时候继续用异常有人说 result 类能完全替代异常我不同意。在日常业务中它们各有职责范围。从我的经验看适合用 result 类处理的是“业务预期内、调用方需要主动决策”的错误比如参数校验失败、资源不存在、权限不足、数据格式错误这些。这类错误是逻辑的正常分支不是 bug流到上层时是需要被处理和转换的。用异常表达它们有两个问题一是容易把预期错误和系统性 bug 混在一起二是性能开销没必要。反过来真正适合异常的是“程序无法预料的、一般代表环境或 bug 的严重错误”比如数据库连接突然断开、底层依赖返回了完全无法解析的数据、代码里出现了空引用。这些情况通常应该抛出让上层只做粗粒度兜底和日志记录而不是在每一层都传递。我在设计接口时有一个朴素原则如果你希望调用方必须处理某个失败那请用Result如果你只是希望调用方在特殊情况下能够躲开灾难那用异常。这个原则在团队协作里尤其重要因为接口签名只能标注返回类型没人能从void或Promisevoid里看出抛异常的可能。选择 result 类等于把“可预期失败”写进了类型契约。最后再分享一个小技巧真要说起来result 类不是银弹它更像一种把错误变成值的思维习惯。我实际用下来最大的收获不是代码量变少而是 review 代码时思路更清晰每个函数是否处理失败、在哪一层处理失败都在类型和调用链上写得很明白不需要靠猜。如果你在生产项目里引入 result 类我建议从一层边界开始不要一上来全局改造。比如先在 web 路由层和数据访问层之间引入Result把原本到处散落的 if/else 收敛到边界转换跑顺了再推给其它模块。边界转换时用一个统一函数把Result转成 HTTP 响应或业务返回值这样既能享受 result 类带来的可组合性又不会把改动一次性铺得太大。最后一个小技巧是约定好Result的语义后千万别在接口里混用两种风格。比如同一个模块一个函数返回Result另一个函数失败时直接 throw会让调用方精神分裂。定好规矩用起来才舒服。