《TypeScript编程实战》3.1 Result/Either 与类型化错误

本节从 throw 的类型黑洞讲起,说明 TypeScript 的函数签名为何无法表达失败路径。随后手写一套零依赖的 Result/Either 判别联合:Ok/Err 构造、isOk/isErr 类型守卫、map/mapErr/andThen 组合子、unwrapOr 兜底与 fromPromise 互转。再讨论异步流程中把错误搬进类型签名的写法,并划清预期业务错误与意外异常的分工。

本节目标:理解 throw 为什么在 TypeScript 里是「类型黑洞」,并用一个零依赖的 Result<T, E> 判别联合把失败路径搬进函数签名;掌握 isOk / isErr 守卫、map / mapErr / andThen 组合子、unwrapOr 兜底与 fromPromise 互转;最终能在真实工程里划清「预期业务错误」与「意外异常」的边界。

3.1 Result/Either 与类型化错误

throw 是一个类型黑洞

TypeScript 的类型系统是结构化的,几乎每个值都有类型——唯独 throw 逃逸在类型检查之外。看一个再普通不过的函数:

function parsePort(raw: string): number {
  const n = Number(raw);
  if (!Number.isInteger(n) || n < 0 || n > 65535) {
    throw new Error(`invalid port: ${raw}`);
  }
  return n;
}

签名 (raw: string) => number 宣称「给我字符串,还你数字」。调用方读类型时,完全看不到这个函数会炸。失败路径只在运行时存在,编译器无法强制任何人处理它。

更别扭的是 catch 子句里的变量。开启 useUnknownInCatchVariables(strict 家族默认包含)后:

try {
  const port = parsePort(process.env.PORT ?? "");
} catch (e) {
  // e 的类型是 unknown,不是 Error
  console.error(e.message);
  // 报错:'e' is of type 'unknown'. ts(18046)
}

编译器只能给你 unknown,因为 JavaScript 允许 throw 任何值——字符串、数字、undefined,甚至是循环引用的对象。也就是说,「错误」这个在业务里最重要的概念,在类型层面是完全隐形的:你不可能在编译期知道一个函数会以什么方式失败。

Result 的思路极其简单:把「可能失败」变成返回类型的一部分。

// 语义等价于:要么成功拿到 T,要么失败拿到 E,没有第三条路
type SafeParse = (raw: string) => Result<number, ParseError>;

定义 Result 判别联合

先给出完整的类型定义。这里刻意不引入任何第三方库,二十行就能覆盖 90% 的场景:

// result.ts
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });

关键在 ok: true / ok: false 这个字段——它是判别属性(discriminant property)。有了它,Result<T, E> 就是一个判别联合,if (r.ok) 和 switch (r.ok) 都会自动收窄类型,不需要任何断言。

组成含义收窄后的访问
Ok<T>成功分支,携带值r.value: T
Err<E>失败分支,携带错误r.error: E
ok 字段判别属性,字面量类型true / false

readonly 不是装饰:它让 Ok 与 Err 在类型层面不可变,避免下游顺手 r.value = ... 破坏语义。

类型守卫与穷尽收窄

判别联合已经能收窄,但把判断封装成守卫函数能让代码更可读,也让收窄在回调里依然生效:

export function isOk<T, E>(r: Result<T, E>): r is Ok<T> {
  return r.ok;
}

export function isErr<T, E>(r: Result<T, E>): r is Err<E> {
  return !r.ok;
}

配合 never 做穷尽性检查,任何新增分支都会被编译器抓住:

function describe(r: Result<number, ParseError>): string {
  if (isOk(r)) return `端口 = ${r.value}`;
  if (isErr(r)) return `解析失败:${r.error.kind}`;
  // 若未来给 Result 加了第三个分支,下面这行会立刻报错
  const exhaustive: never = r;
  throw new Error(`unreachable: ${JSON.stringify(exhaustive)}`);
}

组合子:map / mapErr / andThen

有了容器,就能像操作 Array 一样操作成功值,而不必层层解包:

export function map<T, U, E>(r: Result<T, E>, f: (v: T) => U): Result<U, E> {
  return r.ok ? ok(f(r.value)) : r;
}

export function mapErr<T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> {
  return r.ok ? r : err(f(r.error));
}

// 链式串联:只有成功时才继续,失败时短路
export function andThen<T, U, E>(
  r: Result<T, E>,
  f: (v: T) => Result<U, E>,
): Result<U, E> {
  return r.ok ? f(r.value) : r;
}

真实用法:把「读环境变量 → 解析 → 校验范围」串成一条管线,任意一步失败都自动短路:

type ParseError = { kind: "missing" | "nan" | "out-of-range"; raw: string };

function parsePort(raw: string | undefined): Result<number, ParseError> {
  if (raw === undefined) return err({ kind: "missing", raw: "" });
  const n = Number(raw);
  if (!Number.isInteger(n)) return err({ kind: "nan", raw });
  if (n < 1 || n > 65535) return err({ kind: "out-of-range", raw });
  return ok(n);
}

const result = andThen(
  parsePort(process.env.PORT),
  (port) => map(ok(port), (p) => `http://localhost:${p}`),
);
// result: Result<string, ParseError>

注意 map 里 f 返回的是普通值 U,而 andThen 里 f 返回的是 Result<U, E>。这个区分与 Array.prototype.map / flatMap 完全一致:andThen 相当于 flatMap,用于避免嵌套出 Result<Result<U, E>, E>。

unwrapOr 与 fromPromise

Result 不能永远是「未拆封」的状态,边界处总要落地。unwrapOr 提供带兜底值的解包:

export function unwrapOr<T, E>(r: Result<T, E>, fallback: T): T {
  return r.ok ? r.value : fallback;
}

const port = unwrapOr(parsePort(process.env.PORT), 3000);
// port: number,永不抛异常

与 Promise 互转是工程里的高频需求。Promise 的 reject 本质上就是「异步版 throw」,同样是类型黑洞——Promise<T> 的签名里看不到它。fromPromise 把 reject 收进 Err:

export async function fromPromise<T, E = unknown>(
  p: Promise<T>,
): Promise<Result<T, E>> {
  try {
    return ok(await p);
  } catch (e) {
    return err(e as E);
  }
}
const res = await fromPromise(fetch("https://example.com/api/user").then((r) => r.json()));
if (isErr(res)) {
  // res.error 是 unknown,需要进一步收窄
  console.error("请求失败", res.error);
}

fromPromise 是整个体系里唯一允许出现 try/catch 的地方——它是「不安全的真实世界」与「类型安全的 Result 世界」之间的一道膜。把不确定的异常收口在一个函数里,其余代码就全是纯函数式的组合,非常利于测试。

异步流程:把错误写进签名

把上面的零件拼起来,就是一个端到端类型安全的业务函数:

type UserError =
  | { kind: "not-found"; id: string }
  | { kind: "network"; cause: unknown };

async function loadUser(id: string): Promise<Result<User, UserError>> {
  const res = await fromPromise(fetch(`/api/users/${id}`));
  if (isErr(res)) return err({ kind: "network", cause: res.error });

  if (res.value.status === 404) return err({ kind: "not-found", id });
  if (!res.value.ok) return err({ kind: "network", cause: res.value.statusText });

  const body = await fromPromise<User>(res.value.json());
  return isOk(body) ? ok(body.value) : err({ kind: "network", cause: body.error });
}

调用方现在被迫面对失败分支,因为 UserError 就在签名里:

const r = await loadUser("u_42");
switch (r.ok ? "ok" : r.error.kind) {
  case "ok":
    render(r.value);
    break;
  case "not-found":
    showEmpty(r.error.id);
    break;
  case "network":
    showRetry(r.error.cause);
    break;
}

这段 switch 与 UserError 的联合类型一一对应。将来给 UserError 加一个 { kind: "forbidden" },编译器会在这里报错提醒你补分支——这正是 throw 永远做不到的事。

Result 与 throw 的分工

Result 不是要取代 throw,两者边界清晰:

场景推荐做法理由
预期内的业务失败(校验、未找到、余额不足)Result调用方必须处理,属于正常控制流
编程错误 / 不变量被破坏(越界、非空断言失败)throw应立即崩溃并暴露 bug
第三方库 / 运行时边界try/catch 收口成 Result把不可控的异常隔离在边界
基础设施故障(数据库断开)throw + 上层兜底通常无法就地恢复,交给重试或崩溃

一句话原则:能被合理处理的失败用 Result,不该被处理、只该被修复的失败用 throw。

常见坑与报错对照

坑一:忘了收窄就访问字段。 直接写 r.value 在 Result 上会报错:

Property 'value' does not exist on type 'Result<number, ParseError>'.
  Property 'value' does not exist on type 'Err<ParseError>'. ts(2339)

坑二:err 里塞 string。 字符串错误没有结构,下游无法判别类型:

return err("invalid port"); // 不推荐
return err({ kind: "invalid-port" } as const); // 推荐:可判别

坑三:instanceof 在跨 realm 失效。 Worker、vm、iframe 里的 Error 与主线程的不是同一个构造函数,e instanceof Error 会返回 false。更稳的做法是鸭子类型判断:

function toError(e: unknown): Error {
  if (e instanceof Error) return e;
  if (typeof e === "object" && e !== null && "message" in e) {
    return new Error(String((e as { message: unknown }).message));
  }
  return new Error(String(e));
}

坑四:Result 里嵌套 Result。 混用 map 与 andThen 会得到 Result<Result<U, E>, E>,需要 flatten 或改用 andThen。

坑五:过度使用 Result。 每一层都包 Result 会让签名膨胀。建议只在模块边界(HTTP handler、DB 访问、外部 API)使用,模块内部仍可用 throw,在边界处统一 fromPromise。

想把这条链路做深,可以延伸阅读 TypeScript 错误处理与 Result ,以及更完整的代数效应方案 Effect-TS 编程 ;其他语言里 Result 的原型可参考 Rust 的 Result 与 Option 。

小结

本节把「错误」从一个隐形的运行时概念,变成了类型签名里显式的一部分。要点回顾:

  • throw 不受类型系统约束,catch 变量是 unknown,失败路径无法在编译期被发现。
  • Result<T, E> 用 ok 判别属性构成判别联合,if (r.ok) 即可收窄,配合 never 做穷尽检查。
  • map / mapErr / andThen 让你像操作 Array 一样组合成功值,失败自动短路。
  • unwrapOr 提供带兜底的解包,fromPromise 是 try/catch 的唯一合法收口点。
  • 分工原则:可处理的失败用 Result,该崩溃的 bug 用 throw。

不过 Result 只能覆盖你预料到的失败。那些没预料到的异常——第三方库抛出的、事件回调里逃逸的、Promise 被漏 await 的——仍然需要一个全局兜底。下一节 全局错误边界与未捕获异常 就来讲这最后一道网;在此之前,若你还没把环境变量收口,建议先读 环境变量与配置的类型化 ,因为 parsePort 这类解析正是配置层最常见的用法。

阅读导航:上一节:2.3 调试与 source map · 下一节:3.2 全局错误边界与未捕获异常 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes