《TypeScript编程入门》14.1 错误类型与 Result 模式

本节把「错误」当成类型问题来讨论:为什么 catch 的参数只能是 unknown,如何用自定义错误类配合 instanceof 收窄,以及如何用判别联合把失败编码成返回值。我们会手写一个带 map、mapErr、unwrap 的 Result 类型,并用表格对比异常与 Result 各自的适用边界,读完你能为业务代码选出一套可推导、可穷尽检查的错误处理方案。

本节目标:读完这一节,你能解释为什么 catch (e) 里的 e 类型是 unknown 而不是 Error;能写出带 cause 链的自定义错误类并用 instanceof 正确收窄;能用判别联合定义 Result<T, E> 并把失败变成返回值;能手写 map / mapErr / unwrap 三个基础方法;并且能在「抛异常」与「返回 Result」之间做出有依据的取舍。

14.1 错误类型与 Result 模式

前面 13 章我们一直在处理「正常路径」:数据从哪来、长什么形状、怎么在编译期把它约束住。但从这一章开始,我们要面对另一半现实——事情会出错。网络会断、JSON 会畸形、用户会输入负数、磁盘会满。

TypeScript 的类型系统对「错误」几乎不设防,这是它最容易被忽略的短板之一。这一节先把错误还原成一个类型问题,再给你两套工具:一套是继承自 JavaScript 的异常机制,一套是把失败写进类型里的 Result 模式。

类型系统眼里的「错误」

先看一个所有 TS 新手都会踩的坑:

try {
  JSON.parse("{ 坏掉的 json }");
} catch (e) {
  console.log(e.message); // ❌ 编译错误
}

在 strict 模式下,编辑器会在 e.message 上画红线:

'e' is of type 'unknown'. ts(18046)

很多人第一反应是改成 catch (e: any),或者干脆在 tsconfig.json 里关掉 useUnknownInCatchVariables。这两种做法都是把类型系统的警告当成噪音,而不是当成信息。

正确的理解是:catch 能捕获到的东西,在类型上确实是未知的。 JavaScript 允许抛出任何值,而不只是 Error 实例:

throw "字符串也是合法的 throw";
throw 42;
throw { code: "E_BAD" };
throw undefined; // 甚至这个

既然运行时可能抛出的东西没有形状保证,编译器把 e 推断成 unknown 就是诚实的行为。unknown 是安全的顶层类型——可以赋给 unknown 或 any,但在收窄之前不能访问任何属性。

把 unknown 收窄成 Error

要使用 e.message,必须先向编译器证明它是 Error。最常用的手段是 instanceof:

try {
  riskyOperation();
} catch (e) {
  if (e instanceof Error) {
    console.error(e.name, e.message, e.stack); // ✅ 已收窄
  } else {
    console.error("未知异常:", String(e)); // 字符串、数字、null……
  }
}

instanceof 之所以能收窄,是因为它被 TypeScript 视为类型守卫(详见 7.3 类型守卫与控制流分析 )。而 e.message 这种「先断言再访问」的写法,恰恰是 any 最危险的地方:如果运行时抛出的其实是字符串,e.message 得到 undefined,你会在日志里看到一片空白,问题被掩盖而不是被解决。

一个务实的做法是把它抽成归一化函数:

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  if (typeof value === "string") return new Error(value);
  return new Error(`非 Error 抛出:${String(value)}`);
}

自定义错误类与 instanceof 收窄

内置的 Error 只有 name 和 message 两个字段,不足以承载业务信息。工程上更常见的是定义一组错误类,让调用方按类型分流:

class AppError extends Error {
  constructor(message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = new.target.name; // 子类自动获得自己的类名
  }
}
class ValidationError extends AppError {
  constructor(message: string, readonly field: string) {
    super(message);
  }
}

class NetworkError extends AppError {
  constructor(
    message: string,
    readonly status: number,
    readonly retryable: boolean,
  ) {
    super(message);
  }
}

三处值得展开:

  1. this.name = new.target.name。new.target 指向「实际被 new 的那个类」,子类不用各自重复赋值。默认情况下 new ValidationError("x").name 是 "Error",日志里看不出是哪个类,这是很常见的疏漏。
  2. readonly 参数属性。readonly field: string 写在参数位置,等价于「声明字段 + 赋值」,这是 6.1 类、访问修饰符与参数属性 讲过的语法。
  3. options?: { cause?: unknown }。ES2022 的 Error 支持 cause,用来把底层错误挂在当前错误上,形成因果链。

有了这些类,catch 里的分流就变成了类型层面的 switch:

try {
  await submitForm(input);
} catch (e) {
  const err = toError(e);
  if (err instanceof ValidationError) {
    highlight(err.field);          // ✅ 能拿到 field
  } else if (err instanceof NetworkError) {
    if (err.retryable) scheduleRetry();
  } else {
    reportToSentry(err);
  }
}

cause 链让「错误从哪来」变得可追溯:

try {
  await fetch(url);
} catch (e) {
  throw new NetworkError("请求失败", 0, true, { cause: e });
}

// 层层回溯因果链
let cur: unknown = caught;
while (cur instanceof Error) {
  console.error(cur.name, cur.message);
  cur = cur.cause;
}

用判别联合表达「可能的失败」

自定义错误类解决了「分类」,但没解决「编译器不知道这个函数会不会抛」。类型系统里,抛异常是一条完全隐形的通道:签名 function parse(s: string): User 看起来百分百成功,实际却可能炸掉。

要让它显形,得把失败搬进返回值类型。这就是判别联合(7.2 判别联合(Discriminated Unions) )的主场:

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

ok 字段就是判别式(discriminant)。有了它,调用方在 if (r.ok) 之后,编译器就知道 r.value 一定存在:

function parseAge(input: string): Result<number, ValidationError> {
  const n = Number(input);
  if (!Number.isFinite(n)) {
    return { ok: false, error: new ValidationError("不是数字", "age") };
  }
  if (n < 0 || n > 150) {
    return { ok: false, error: new ValidationError("超出范围", "age") };
  }
  return { ok: true, value: n };
}

const r = parseAge("42");
if (r.ok) {
  console.log(r.value.toFixed(0)); // ✅ number
} else {
  console.log(r.error.field);      // ✅ ValidationError
}

失败路径从此无法被静默忽略。对比一下「失败返回 undefined」的老写法:调用方忘了判空也能编译通过,直到线上崩掉;而用判别联合时,只要你试图访问 r.value,编译器就会要求你先证明 r.ok。

给 Result 加上 map 与 mapErr

每次都手写 if (r.ok) 很啰嗦。给 Result 配几个组合子,就能把嵌套判断拍平成链式调用:

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

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

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

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

注意 ok 与 err 的返回类型里出现了 never:Result<T, never> 表示「一个永远不失败的 Result」,这样在联合使用时错误类型会被自动收窄——never | E 就是 E,这是 never 作为「零元」的用法,详见 3.3 any·unknown·never·void 与类型断言 。

组合子用起来是这样:

const doubled = map(parseAge("21"), (n) => n * 2);
// { ok: true, value: 42 }

const withCode = mapErr(parseAge("abc"), (e) => ({
  code: "INVALID",
  detail: e.message,
}));
// { ok: false, error: { code: "INVALID", detail: "不是数字" } }

mapErr 的典型用途是把底层错误翻译成对外契约里的错误码,这与 13.3 API 契约与边界数据校验 讲的边界思路一脉相承:内部实现随意,出口必须收敛成稳定的形状。

unwrap:在边界处把 Result 转回异常

Result 链的末端通常要回到调用方约定的形式。unwrap 负责在「确认无错」时取值、在有错时抛出:

function unwrap<T, E>(r: Result<T, E>): T {
  if (r.ok) return r.value;
  throw r.error instanceof Error ? r.error : new Error(String(r.error));
}

const age = unwrap(parseAge("30")); // 30
const bad = unwrap(parseAge("x"));  // 抛出 ValidationError

反过来,把可能抛错的函数包成 Result,用于在系统边界拦截异常:

function attempt<T>(fn: () => T): Result<T, Error> {
  try {
    return ok(fn());
  } catch (e) {
    return err(toError(e));
  }
}

const parsed = attempt(() => JSON.parse(text));

attempt 只应在解析外部输入、调用第三方库、读写磁盘这类地方使用,不要在业务逻辑内部到处包一层——那会把异常机制退化成一堆包装,反而增加噪音。

什么时候用异常,什么时候用 Result

维度抛异常返回 Result
编译器是否强制处理否是(必须判 ok)
签名是否体现失败否是(错误类型进泛型)
适合场景编程错误、不可恢复故障、深层透传预期内的业务失败(校验、余额不足、限流)
跨边界传播自动向上冒泡需逐层返回或显式转换
与生态兼容好(Promise、框架都基于异常)需在边界做一次转换
穷尽性检查靠 instanceof 链,可能漏靠判别联合,可穷尽

一条实用的经验法则:「预期会发生」的失败用 Result,「不应该发生」的用异常。 用户输错密码是预期的,用 Result;数据库连接串配错了是故障,让它抛。

生态里已有一套成熟的 Result 实现(Effect 的 Either、neverthrow 的 Result),它们额外提供了 andThen(链式短路)、match(模式匹配)等组合子。想看工程化的完整形态,可延伸阅读既有专题 /typescript-error-handling-result/ 与 /typescript-effect-ts-programming/ 。

常见坑与报错

坑一:catch (e: any) 绕过检查。

Catch clause variable type annotation must be 'any' or 'unknown' if specified. ts(1196)

可以写 catch (e: any),但那是在关掉编译器给你的安全网。优先用 instanceof 收窄。

坑二:instanceof 在跨 realm 时失效。 错误对象若来自另一个 iframe、vm 上下文或 worker,它的 Error 构造函数与当前上下文不是同一个,instanceof Error 会返回 false。这种情况改用鸭子类型判断:

function isErrorLike(v: unknown): v is { message: string } {
  return typeof v === "object" && v !== null && "message" in v;
}

坑三:忘了 this.name 赋值。 子类错误的 name 默认继承父类的 "Error",日志里全是 Error,无法区分。用 this.name = new.target.name 一次解决。

坑四:ES5 target 下 instanceof 自定义 Error 失败。 编译到 ES5 时 extends Error 会丢失原型链。要么把 target 提到 ES2015 以上(见 16.1 编译目标与严格模式配置 ),要么补一行 Object.setPrototypeOf(this, new.target.prototype)。

坑五:Result 的 E 用 unknown。 那等于放弃了错误类型的信息量,r.error 什么都访问不了。给每个边界定义具体的错误联合:

type ApiError =
  | { kind: "network"; retryable: boolean }
  | { kind: "validation"; field: string }
  | { kind: "server"; status: number };

坑六:把 throw 写在 Result 风格函数里。 混用两套机制会让调用方无法判断该 try 还是该判 ok。一个函数内部可以二选一,但对外只暴露一种契约。

一个真实工程示例

把本节的东西串起来,看一个「解析配置」的完整流程:

type ConfigError =
  | { kind: "parse"; line: number }
  | { kind: "validate"; field: string };

function loadConfig(raw: string): Result<{ port: number }, ConfigError> {
  let data: unknown;
  try {
    data = JSON.parse(raw); // 边界处:异常转 Result
  } catch {
    return err({ kind: "parse", line: 0 });
  }
  if (typeof data !== "object" || data === null) {
    return err({ kind: "validate", field: "root" });
  }
  const obj = data as Record<string, unknown>;
  if (typeof obj.port !== "number") {
    return err({ kind: "validate", field: "port" });
  }
  return ok({ port: obj.port });
}

// 调用方:判别联合让每种错误都能被穷尽处理
const result = loadConfig(readFile());
switch (result.ok ? "ok" : result.error.kind) {
  case "ok":
    startServer(result.value.port);
    break;
  case "parse":
    console.error(`第 ${result.error.line} 行不是合法 JSON`);
    break;
  case "validate":
    console.error(`字段 ${result.error.field} 不合法`);
    break;
  default:
    assertNever(result); // 若漏了分支,这里会编译报错
}

function assertNever(x: never): never {
  throw new Error(`未处理的错误分支:${JSON.stringify(x)}`);
}

最后这段 switch 是整节的落点:result.error.kind 是字面量联合,编译器知道所有分支;一旦漏写某个 case,assertNever(result) 的参数就不再是 never,于是错误处理的完备性第一次由编译器保证,而不是靠代码评审。

到这里,我们已经能给「失败」建模了。但真实世界里的失败大多发生在异步路径上——网络请求、定时器、文件读取。下一节 14.2 Promise 与 async/await 的类型 会把 Promise 的类型参数拆开看,并解释为什么 await 之后的 try/catch 依然是 unknown。

小结

  • catch (e) 的参数类型是 unknown,因为 JavaScript 允许抛出任何值;先用 instanceof Error 或自定义守卫收窄,再访问属性。
  • 自定义错误类要显式设置 this.name = new.target.name,并用 cause 串起因果链;ES5 target 下需 Object.setPrototypeOf 修补原型。
  • Result<T, E> 用判别联合把失败编码进返回值,让「忘记处理错误」变成编译错误;ok / err 的 never 参数使错误类型自动收窄。
  • map / mapErr 在成功或失败单侧做变换,unwrap 在边界处转回异常,attempt 在边界处把异常转成 Result。
  • 取舍原则:预期内的业务失败用 Result,编程错误与不可恢复故障用异常;同一个函数对外只暴露一种契约。
  • assertNever 配合 switch 能把错误分支的穷尽性交给编译器检查。

阅读导航:上一节:13.3 API 契约与边界数据校验 · 下一节:14.2 Promise 与 async/await 的类型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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