Node.js 错误边界的类型安全工程:从 catch 丢弃到 Result 与进程守护的分层治理
引言
Node.js 的错误处理有两层隐患:一是异步错误天然容易被 Promise 链吞掉,二是 catch (err) 里的 err 在 TS 里默认是 any,等于放弃了类型的最后一道防线。于是错误既可能在运行时静默消失,也可能在编译期失去约束。本文把这两层问题合成一个命题:用类型系统让错误显形,用进程守护兜住漏网之鱼。
一、catch 里的 err 是 any,类型安全从这里断裂
TS 无法静态知道 catch 捕获的是什么,于是 err 默认是 any 或 unknown。直接访问 err.message 会污染下游。
// 反例:catch 里 err 是 any,属性访问无约束,还可能抛二次异常
try {
await fetchUser(id);
} catch (err) {
console.log(err.message); // err 是 any,message 可能 undefined,且无类型检查
throw err; // 原样抛出 any,类型信息全丢
}
// 正例:显式收窄 err 为 Error,把未知收敛成可判读的结构
try {
await fetchUser(id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`fetchUser 失败:${message}`, { cause: err });
}
instanceof Error 把 unknown 收窄成确定的结构,再抛出一个携带 cause 的新错误。类型信息不再断链,原始错误也保留在 cause 里供排查。
二、用 Result 类型让「失败」成为返回值,而非异常
异常适合「真正意外」的错误;可预期的失败(校验不过、资源不存在)用返回 Result 更清晰,也更能被类型系统追踪。
// 反例:可预期错误也走 throw,调用方被迫 try-catch
function parseConfig(raw: string): Config {
try {
return JSON.parse(raw); // 格式错误被当成异常抛出
} catch {
throw new Error('配置解析失败');
}
}
// 调用方:每个都要 try-catch,样板爆炸
// 正例:Result 把失败建模为返回值,类型强制调用方处理
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseConfig(raw: string): Result<Config> {
try {
return { ok: true, value: JSON.parse(raw) };
} catch {
return { ok: false, error: new Error('配置解析失败') };
}
}
// 调用方:类型收窄强制处理失败分支
const r = parseConfig(raw);
if (!r.ok) return r.error; // 编译器保证:不处理 error 分支无法继续
Result 把「可能失败」写进返回类型,调用方被类型系统强制处理失败分支,不能再「忘了 catch」。这是异常与返回值的分工:预期内失败走值,预期外崩溃走异常。
三、Promise 拒绝不能被吞掉
异步里最隐蔽的是「创建了 Promise 却没接住它的拒绝」。TS 不会报错,运行时却留下一个 unhandled rejection。
// 反例:fire-and-forget 的 Promise,拒绝无人处理
async function send(payload: unknown) {
await db.write(payload);
}
// 调用处不 await,也不 catch,失败静默丢失
send(data); // db.write 抛错时,这里无感知
// 正例:要么 await,要么显式接住拒绝,并打点
async function send(payload: unknown) {
await db.write(payload);
}
// 方案一:await + try-catch
async function run() {
try { await send(data); } catch (err) { report(err); }
}
// 方案二:不能 await 的场景,显式 .catch 兜底
send(data).catch((err) => report(err));
// 兜底:进程级捕获 unhandledRejection,避免进程默默死亡
process.on('unhandledRejection', (reason) => {
console.error('unhandled rejection:', reason);
// 记录后决定:退出 or 继续,绝不能静默
});
三管齐下:能 await 就 await,不能就显式 .catch,最后用 unhandledRejection 兜住漏网。任何一层缺失,拒绝都会在某处悄悄蒸发。
四、进程级守护:未捕获异常与优雅退出
Node 是单线程,一个未捕获异常默认会崩掉整个进程。工程化的方式是「分类处理 + 优雅退出」,而不是靠 process.on 强行续命。
// 反例:捕获 uncaughtException 后继续跑,进程状态已不可信
process.on('uncaughtException', (err) => {
console.error(err);
// 不退出:进程可能已处于损坏状态,继续运行是危险的
});
// 正例:记录后优雅退出,交由 PM2/systemd 重启
import { server } from './app';
function shutdown(signal: string) {
console.log(`${signal} 收到,开始优雅关闭`);
server.close(() => {
console.log('HTTP 连接已关闭,进程退出');
process.exit(0);
});
// 超时强制退出,避免挂死
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('未捕获异常,进程即将退出:', err);
process.exit(1); // 让进程管理器重启,而不是带伤续跑
});
uncaughtException 意味着进程状态已经不可信,正确的做法是记录 + 退出 + 交给进程管理器重启。SIGTERM 的优雅关闭则保证在退出前把正在处理的请求写完。
五、错误治理的四层结构
把上面的做法叠成一个清晰的分层,每层各司其职:
// 正例:错误治理四层
// L1 边界收窄:catch 里 instanceof Error,把 unknown 转成 Error
// —— 治「类型断链」
// L2 值/异常分工:预期失败用 Result,意外才 throw
// —— 治「try-catch 样板爆炸」
// L3 拒绝显形:能 await 则 await,否则 .catch,进程监听 unhandledRejection
// —— 治「异步错误蒸发」
// L4 进程守护:uncaughtException 记录即退出,SIGTERM 优雅关闭
// —— 治「带伤续跑」
这四层互为补充:L1/L2 管「编译期让错误的类型显形」,L3/L4 管「运行时让漏网的错误被接住」。没有前三层,第四层会沦为「崩溃日志收集器」;没有第四层,前三层也挡不住真正的意外崩溃。
结语
Node.js 的错误治理,本质是「类型」与「运行时」的双向奔赴:用 TypeScript 的收窄和 Result 让错误在进入运行前就被类型系统看见,用 unhandledRejection 和 uncaughtException 让那些类型系统也抓不到的意外在运行时被接住。当每一个 catch 都不再是 any 黑洞、每一个异步拒绝都有去处时,错误的「静默丢失」才会真正消失——而这是任何日志系统都替代不了的、写在类型层的第一道防线。
评论区
登录 后参与评论