本节目标:读完这一节,你能解释为什么
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);
}
}
三处值得展开:
this.name = new.target.name。new.target指向「实际被new的那个类」,子类不用各自重复赋值。默认情况下new ValidationError("x").name是"Error",日志里看不出是哪个类,这是很常见的疏漏。readonly参数属性。readonly field: string写在参数位置,等价于「声明字段 + 赋值」,这是 6.1 类、访问修饰符与参数属性 讲过的语法。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 的类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。