前端开发··1 阅读·预计 11 分钟

Node.js 错误边界的类型安全工程:从 catch 丢弃到 Result 与进程守护的分层治理

引言

Node.js 的错误处理有两层隐患:一是异步错误天然容易被 Promise 链吞掉,二是 catch (err) 里的 err 在 TS 里默认是 any,等于放弃了类型的最后一道防线。于是错误既可能在运行时静默消失,也可能在编译期失去约束。本文把这两层问题合成一个命题:用类型系统让错误显形,用进程守护兜住漏网之鱼

一、catch 里的 errany,类型安全从这里断裂

TS 无法静态知道 catch 捕获的是什么,于是 err 默认是 anyunknown。直接访问 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 Errorunknown 收窄成确定的结构,再抛出一个携带 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 继续,绝不能静默
});

三管齐下:能 awaitawait,不能就显式 .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 让错误在进入运行前就被类型系统看见,用 unhandledRejectionuncaughtException 让那些类型系统也抓不到的意外在运行时被接住。当每一个 catch 都不再是 any 黑洞、每一个异步拒绝都有去处时,错误的「静默丢失」才会真正消失——而这是任何日志系统都替代不了的、写在类型层的第一道防线。

0 评论

评论区

登录 后参与评论