TypeScript 错误处理：用 Result 类型替代 try-catch

在 TypeScript 的开发中，抛出错误然后由调用方 try-catch 捕获是非常常见的模式。然而，这个我们习以为常的模式，其实隐藏着一个巨大的类型安全陷阱。 try-catch 的类型困境看看下面这段再普通不过的代码： try { somethingThatWillThrowError(); } catch (e) { // e 的类型是 unknown —— 完全不知道这是什么错误 console.error(e); } 在 catch 块中，e 的类型是 unknown。你完全无法知道它到底是什么错误，更别提根据不同的错误类型做不同的处理了。这在其他语言中并不是问题。比如 Java 会在函数签名中通过 throws 关键字明确声明可能抛出的异常类型；Rust 和 Go 则直接让你返回错误，强制你在调用处处理它。而 TypeScript 呢？什么都不告诉你。自定义错误类：一个看似优雅的方案你可能会想："我定义几个自定义错误类不就好了？" class MyError extends Error { constructor(message: string = "My Error") { super(message); this.name = "MyError"; } } class YourError extends Error { constructor(message: string = "Your Error") { super(message); this.name = "YourError"; } } 然后在函数中抛出这些自定义错误： function somethingThatWillThrowError() { // ... if (someCondition) { throw new MyError(); } // ... if (anotherCondition) { throw new YourError(); } } 在调用处用 instanceof 来区分处理： try { somethingThatWillThrowError(); } catch (e) { if (e instanceof MyError) { // 处理 MyError... } else if (e instanceof YourError) { // 处理 YourError... } else { // 未知错误 } } 看起来还不错对吧？但这么做有个巨大的隐患。隐患在哪？因为抛出错误的地方和处理错误的地方是完全分离的。当你（或者其他开发者）日后在 somethingThatWillThrowError 函数里新增了一种自定义错误，或者删除了某个已有的错误类型，你还得记着去所有调用它的地方同步修改 catch 中的处理逻辑。这非常不可靠 —— 就算忘记处理了，编译也完全不会报错。对于追求类型安全的 TypeScript 项目来说，这是不可接受的。更优雅的方案：Result 类型那有没有一种方式，能让 TypeScript 在编译时就告诉你有哪些错误需要处理，漏了就报错？有的兄弟，有的！那就是 Result 类型。定义 Result 类型先来看看 Result 类型长什么样： export type Result = | { data: TData; error: null; } | { error: TError; data: null; }; 这是一个很简洁的可辨识联合类型（Discriminated Union）。它表达了一个非常清晰的语义：一个操作的结果，要么是成功的数据，要么是带有 reason 的错误，二者不会同时存在。接下来定义两个工具函数，让创建 Result 对象更加方便： export function ok(data: TData): Result { return { data, error: null }; } export function err< const TReason extends string, TError extends { reason: TReason }, >(error: TError): Result { return { error, data: null }; } 这里有个关键技巧：err 函数的泛型参数 TReason 前面加了一个 const 关键字。这是 TypeScript 5.0 引入的 const 类型参数，它告诉 TypeScript 将 TReason 推断为字面量类型（如 "MY_ERROR"），而不是宽泛的 string 类型。这是实现完整类型安全的关键一步。改造函数现在用 ok 和 err 来改造之前的函数 —— 不再 throw，而是 return： function somethingThatWillThrowError() { // ... if (someCondition) { return err({ reason: "MY_ERROR" }); } // ... if (anotherCondition) { return err({ reason: "YOUR_ERROR" }); } return ok("okokok"); } 注意，函数不再抛出错误，而是将错误作为返回值的一部分。TypeScript 会自动推断出这个函数的返回类型是： Result | Result; 所有可能的错误类型，都被编码在了返回类型中。类型安全的错误处理最后，在调用方可以这样优雅地处理错误： const result = somethingThatWillThrowError(); if (result.error) { const reason = result.error.reason; switch (reason) { case "MY_ERROR": // 处理 MY_ERROR break; case "YOUR_ERROR": // 处理 YOUR_ERROR break; default: { // 穷尽检查：如果所有错误都处理了，reason 的类型会收窄为 never console.error(`未处理的错误：${reason satisfies never}`); } } return; } // 走到这里，TypeScript 知道 result.error 是 null，result.data 一定有值 console.log(result.data); // 类型是 string 为什么这能解决问题？关键就在 default 分支中的 reason satisfies never。 satisfies 是 TypeScript 4.9 引入的关键字，它可以在不改变变量类型的前提下验证表达式是否满足某个类型。这里我们用来确保 reason 的类型已经被收窄为 never —— 也就是说，所有可能的错误都在上面的 case 中被处理了。这带来了双向的编译时保护：新增了错误但忘记处理？ reason 的类型不会收窄为 never，satisfies never 会编译报错 ❌ 处理了一个已经被删除的错误？ 对应的 case 分支会报错，因为该字面量类型已不存在 ❌ 实实在在的类型安全，不依赖人的记忆力，完全由编译器帮你兜底。总结方案类型安全编译时检查维护成本原生 try-catch ❌ unknown 类型 ❌ 无高自定义错误 + instanceof ⚠️ 部分 ❌ 无高 Result 类型 ✅ 完全 ✅ 穷尽检查低 Result 类型并不是什么新鲜概念，它在 Rust（Result）、Haskell（Either）等语言中早已是标准做法。在 TypeScript 中，借助可辨识联合类型、const 类型参数和 satisfies 关键字，我们同样可以实现优雅且类型安全的错误处理。下次写 TypeScript 的时候，不妨试试用 Result 类型替代 try-catch，你的代码会更健壮，你的队友也会感谢你的。参考 How To Handle Errors Like A Senior Dev - YouTube TypeScript 5.0 - const Type Parameters TypeScript 4.9 - satisfies Operator