第 14 章

错误处理类型化:Result<T,E> 模式替代 try/catch

第14章:错误处理类型化:Result<T,E> 模式替代 try/catch

理解错误处理类型化是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用Result<T,E> 模式替代 try/catch?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

try/catch 的类型系统盲区

TypeScript 4.0 之前,catch 子句的变量是隐式 any。4.0 之后变成了 unknown,这是进步——但也意味着你必须做类型收窄才能使用错误对象。更根本的问题是:函数签名里看不见错误

// 你调用这个函数,签名告诉你它返回 User
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);  // 隐形!
  const user = await res.json();
  if (!user) throw new Error("User not found");        // 隐形!
  return user;
}

// 调用方:不读源码根本不知道会抛什么
try {
  const user = await getUser("123");
} catch (e) {
  // e 是 unknown,你不知道是 NetworkError? NotFoundError? ValidationError?
  if (e instanceof Error) {
    console.error(e.message); // 勉强能用,但失去了错误类型信息
  }
}

函数签名 Promise<User> 对可能发生的错误沉默,这造成了两个后果:调用方不知道要处理什么错误;代码审查时无法从签名判断可靠性。


四种错误处理方案对比

方案 代表 优点 缺点
抛出异常 Java, JS 传统 写法简单 错误类型不可见;调用方可以不处理
返回 null JS 常见做法 简单 丢失错误原因;null 可以不检查就使用
返回 [error, value] 元组 Go 风格 可见 容易忘记检查第一个元素;两个都是 null 时语义不清
返回 Result<T, E> Rust, Haskell 错误类型可见;编译器强制处理 需要辅助函数;不熟悉的人需要学习成本
// 方案 2:返回 null —— 错误原因丢失
async function findUser(id: string): Promise<User | null> {
  // 网络失败? 用户不存在? 权限不足? 都是 null
  return db.users.findById(id);
}

// 方案 3:Go 风格元组 —— 可以忘记检查
async function findUser(id: string): Promise<[Error | null, User | null]> {
  try {
    const user = await db.users.findById(id);
    return [null, user];
  } catch (e) {
    return [e as Error, null];
  }
}

const [err, user] = await findUser("123");
user.name; // 忘记检查 err!运行时可能崩溃,编译器不警告

实现 Result<T, E>

// 核心类型定义
type Ok<T>     = { ok: true;  value: T };
type Err<E>    = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

// 构造函数(辅助函数)
function ok<T>(value: T): Ok<T> {
  return { ok: true, value };
}

function err<E>(error: E): Err<E> {
  return { ok: false, error };
}

// 类型守卫
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
  return result.ok === true;
}

function isErr<T, E>(result: Result<T, E>): result is Err<E> {
  return result.ok === false;
}

Level 2 · 它是怎么运行的(3-5年经验)

链式操作:map、flatMap、mapError

手动检查每个 result.ok 很快就会变得冗长。链式操作让 Result 的组合更流畅。

// map:成功时转换值,失败时透传错误
function map<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  if (result.ok) return ok(fn(result.value));
  return result; // 透传 Err,不需要 as any
}

// flatMap:成功时执行可能失败的操作(单子绑定)
function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  if (result.ok) return fn(result.value);
  return result;
}

// mapError:失败时转换错误类型,成功时透传
function mapError<T, E, F>(
  result: Result<T, E>,
  fn: (error: E) => F
): Result<T, F> {
  if (!result.ok) return err(fn(result.error));
  return result;
}

// 使用示例
type ParseError = { code: "PARSE_ERROR"; raw: string };
type NetworkError = { code: "NETWORK_ERROR"; status: number };

function parseId(raw: string): Result<number, ParseError> {
  const n = parseInt(raw, 10);
  if (isNaN(n)) return err({ code: "PARSE_ERROR", raw });
  return ok(n);
}

async function fetchUserById(id: number): Promise<Result<User, NetworkError>> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) return err({ code: "NETWORK_ERROR", status: res.status });
  return ok(await res.json());
}

// 链式:parseId → fetchUserById,错误类型各自独立
async function getUser(rawId: string): Promise<Result<User, ParseError | NetworkError>> {
  const idResult = parseId(rawId);
  if (!idResult.ok) return idResult;
  return fetchUserById(idResult.value);
}

真实案例:用户注册流程

用户注册涉及多个独立失败点:邮箱格式、密码强度、用户名是否已被占用、数据库写入。

// 定义所有可能的错误类型
type RegistrationError =
  | { code: "INVALID_EMAIL";    email: string }
  | { code: "WEAK_PASSWORD";    reason: string }
  | { code: "USERNAME_TAKEN";   username: string }
  | { code: "DATABASE_ERROR";   message: string };

interface RegistrationInput {
  email: string;
  password: string;
  username: string;
}

interface User {
  id: string;
  email: string;
  username: string;
}

// 每一步验证都返回 Result
function validateEmail(email: string): Result<string, RegistrationError> {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return err({ code: "INVALID_EMAIL", email });
  }
  return ok(email.toLowerCase());
}

function validatePassword(password: string): Result<string, RegistrationError> {
  if (password.length < 8) {
    return err({ code: "WEAK_PASSWORD", reason: "Password must be at least 8 characters" });
  }
  if (!/[A-Z]/.test(password)) {
    return err({ code: "WEAK_PASSWORD", reason: "Password must contain an uppercase letter" });
  }
  if (!/[0-9]/.test(password)) {
    return err({ code: "WEAK_PASSWORD", reason: "Password must contain a digit" });
  }
  return ok(password);
}

async function checkUsernameAvailable(
  username: string
): Promise<Result<string, RegistrationError>> {
  const existing = await db.users.findByUsername(username);
  if (existing) {
    return err({ code: "USERNAME_TAKEN", username });
  }
  return ok(username);
}

async function createUserRecord(
  email: string,
  password: string,
  username: string
): Promise<Result<User, RegistrationError>> {
  try {
    const user = await db.users.create({ email, password: await hash(password), username });
    return ok(user);
  } catch (e) {
    return err({ code: "DATABASE_ERROR", message: (e as Error).message });
  }
}

// 完整注册流程:错误类型在签名里完全可见
async function registerUser(
  input: RegistrationInput
): Promise<Result<User, RegistrationError>> {
  const emailResult = validateEmail(input.email);
  if (!emailResult.ok) return emailResult;

  const passwordResult = validatePassword(input.password);
  if (!passwordResult.ok) return passwordResult;

  const usernameResult = await checkUsernameAvailable(input.username);
  if (!usernameResult.ok) return usernameResult;

  return createUserRecord(emailResult.value, input.password, usernameResult.value);
}

// 调用方:错误类型完全已知,可以精确处理
async function handleRegistration(input: RegistrationInput) {
  const result = await registerUser(input);

  if (!result.ok) {
    switch (result.error.code) {
      case "INVALID_EMAIL":
        showFieldError("email", `Invalid email: ${result.error.email}`);
        break;
      case "WEAK_PASSWORD":
        showFieldError("password", result.error.reason);
        break;
      case "USERNAME_TAKEN":
        showFieldError("username", `"${result.error.username}" is already taken`);
        break;
      case "DATABASE_ERROR":
        showGlobalError("Registration failed. Please try again.");
        console.error(result.error.message);
        break;
    }
    return;
  }

  // result.value 是 User,类型安全
  redirectToDashboard(result.value.id);
}

把 try/catch 转换为 Result

对于不受你控制的 API(例如 JSON.parse、第三方库),用一个包装函数转换为 Result。

// 通用 try/catch 包装
function tryCatch<T>(fn: () => T): Result<T, Error> {
  try {
    return ok(fn());
  } catch (e) {
    return err(e instanceof Error ? e : new Error(String(e)));
  }
}

async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
  try {
    return ok(await fn());
  } catch (e) {
    return err(e instanceof Error ? e : new Error(String(e)));
  }
}

// 使用
const parsed = tryCatch(() => JSON.parse(rawString));
if (!parsed.ok) {
  console.error("JSON parse failed:", parsed.error.message);
  return;
}
const data = parsed.value; // 类型安全

// 包装 fetch
async function safeFetch(url: string): Promise<Result<Response, Error>> {
  return tryCatchAsync(() => fetch(url));
}

什么时候仍然应该 throw

Result 不是万能的。以下情况应该继续抛出异常:

// 1. 编程错误(invariant 违反):永远不应该发生的情况
function getElement(index: number, arr: readonly unknown[]): unknown {
  if (index < 0 || index >= arr.length) {
    // 这是调用方的 bug,不是运行时的预期失败
    throw new RangeError(`Index ${index} out of bounds for array of length ${arr.length}`);
  }
  return arr[index];
}

// 2. 构造函数内部(构造函数无法返回 Result)
class Config {
  constructor(raw: unknown) {
    if (!isValidConfig(raw)) {
      throw new Error("Invalid config"); // 没有选择
    }
  }
}

// 3. 真正意外的错误(内存不足、系统崩溃)
// 这类错误无法在业务逻辑层有意义地处理,应该让它冒泡到顶层

// Result 适用:业务逻辑的预期失败路径
// throw 适用:程序错误、构造函数、不可恢复的系统错误

Level 3 · 规范怎么定义的(资深)

Result<T, E> 模式源自 Rust 和 Haskell 的错误处理设计。与 try/catch 的根本区别在于:Result 把错误变成了函数签名的一部分,编译器可以强制调用方处理错误。在范畴论中,Result 是一个 Monad——map 对应 functor 的 fmapflatMap 对应 monadic bind。TypeScript 4.0 将 catch 变量类型改为 unknown 是向类型安全迈出的重要一步,但 throw 机制本身仍是不可类型化的——这是 Result<T, E> 模式存在的根本原因。

Level 4 · 边界与陷阱(所有人)

反模式:Result 用在实现细节里

// 反例:内部私有函数也用 Result,过于冗长
class UserParser {
  private parseField(raw: unknown): Result<string, Error> {
    // 这是私有实现细节,调用方就是本类,完全可以直接 throw
    if (typeof raw !== "string") return err(new Error("not a string"));
    return ok(raw);
  }

  parse(data: unknown): Result<User, Error> {
    const nameResult = this.parseField((data as any).name);
    if (!nameResult.ok) return nameResult;
    // ...
  }
}
// 正解:内部实现用 throw,公共接口用 Result
class UserParser {
  private parseField(raw: unknown): string {
    if (typeof raw !== "string") throw new Error("not a string");
    return raw; // 简洁
  }

  parse(data: unknown): Result<User, Error> {
    return tryCatch(() => {
      // parseField 的 throw 在这里被 tryCatch 捕获并转换为 Result
      return {
        name: this.parseField((data as any).name),
        email: this.parseField((data as any).email),
      };
    });
  }
}

汇总

方案 错误可见性 强制处理 链式组合 适用范围
throw 隐形 编程错误、构造函数
T | null 有,但丢失原因 简单"找不到"场景
[E, T] 元组 不推荐
Result<T, E> 完整 优秀 业务逻辑失败路径

下一章预告

第 15 章进入 async/Promise 类型Promise.all 的元组推导、Awaited<T> 工具类型、以及 async 函数的错误类型问题——与本章的 Result<T, E> 结合,构建完整的异步类型安全体系。

本章评分
4.6  / 5  (20 评分)

💬 留言讨论