错误信息怎么写,才能帮未来的自己
凌晨两点,线上报警响了。你打开日志,看到一行:Error: failed。
这是你自己写的代码。三个月前的你。你盯着这行日志,开始从头翻调用栈、查 git log、猜当时到底哪个分支出了问题。十五分钟过去了,你还没定位到根因——不是因为问题复杂,而是因为错误信息根本没给你任何线索。
这种场景我经历过不止一次。每次都会提醒自己:错误信息不是代码的附属品,它是排障的第一现场。写得好,几分钟就能找到方向;写得差,就只能盲猜。
一条好的错误信息该回答什么问题
Nielsen Norman Group 在错误信息设计指南里提过一个框架:好的错误信息至少要回答三个问题——发生了什么、为什么发生、下一步该怎么办 1。这个框架对开发者同样适用,只不过我们的「用户」经常是半年后回来看日志的自己。
我习惯把它拆成四个要素:
| 要素 | 回答的问题 | 示例 |
|---|---|---|
| 动作 | 哪个操作失败了? | 「读取文章分类配置时失败」 |
| 上下文 | 在什么条件下失败的? | 「文件路径: config/meta.json, 请求 ID: req_abc123」 |
| 原因 | 可能的根因是什么? | 「文件不存在」或「JSON 格式不合法」 |
| 下一步 | 接下来该怎么排查? | 「请确认环境变量 CONFIG_PATH 是否正确配置」 |
四个要素不一定要全部出现在每条日志里,但写错误信息时先过一遍这四个维度,能避免大部分「写了一句等于没写」的问题。
从「file error」到有用信息:一个真实的改进过程
我之前维护过一个构建脚本,负责读取内容目录的 meta.json 配置文件。最初的错误处理是这样的:
// 改之前:只知道失败了,不知道为什么
try {
const data = await fs.readFile(filePath)
return JSON.parse(data.toString())
} catch (err) {
throw new Error('file error')
}某天配置文件路径改了,构建直接崩了,日志里就一行 file error。我花了二十分钟才确认是路径问题——因为错误信息完全没告诉我哪个文件、什么错误。
改进后的版本:
// 改之后:动作 + 上下文 + 原因 + 下一步
async function loadMetaConfig(filePath: string) {
try {
const raw = await fs.readFile(filePath, 'utf-8')
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
throw new Error(
`读取文章分类配置失败 [文件: ${filePath}]` +
(code === 'ENOENT'
? ' — 文件不存在,请检查 CONFIG_PATH 环境变量是否指向正确目录'
: code === 'EACCES'
? ' — 权限不足,请检查当前用户是否有读取权限'
: ` — 系统错误码: ${code}`)
)
}
try {
return JSON.parse(raw)
} catch (err) {
throw new Error(
`解析配置文件 JSON 失败 [文件: ${filePath}] — ` +
`请检查 JSON 语法是否正确(常见原因:多余逗号、缺少引号)`
)
}
}改动不大,但排障体验完全不同。同样是路径写错,现在日志直接告诉你文件路径和可能原因,不用再去翻代码确认是哪个 readFile 出的问题。
API 错误:用户看到的和开发者看到的要分开
做过 API 的同学应该都见过这种响应:
{
"error": "something went wrong"
}这种信息对用户和开发者都没用。对用户来说不知道发生了什么,对开发者来说不知道从哪里查起。
2024 年 IETF 发布了 RFC 9457「Problem Details for HTTP APIs」2,定义了 API 错误响应的标准结构。核心思路是把错误信息分成机器可读和人类可读两层:
{
"type": "https://api.example.com/errors/quota-exceeded",
"title": "请求配额已用尽",
"status": 429,
"detail": "当前 API Key 本小时已使用 1000 次请求,达到速率限制上限",
"instance": "/api/v1/articles?page=42",
"retryAfter": 1800
}把这个和常见的 bare status code 对比一下:
| 维度 | 裸状态码 | RFC 9457 结构化响应 |
|---|---|---|
| 人类可读性 | 只知道「400 Bad Request」 | 知道「哪个字段格式不对」 |
| 机器可处理 | 只能按 HTTP 状态码分支 | 可按 type URI 做精确匹配 |
| 文档关联 | 无 | type 字段指向文档页面 |
| 定位问题 | 盲猜 | instance 指向具体资源 |
| 重试指引 | 无 | 可携带 retryAfter 等扩展字段 |
在代码层面,错误处理也应该区分面向用户的提示和面向开发者的日志:
// 面向用户的提示:简洁、可操作、不带技术细节
function getUserMessage(error: AppError): string {
switch (error.code) {
case 'INVALID_EMAIL':
return '邮箱格式不正确,请检查后重新输入'
case 'QUOTA_EXCEEDED':
return '今日免费额度已用完,明天再试试,或升级套餐'
default:
return '出了点问题,请稍后重试(错误编号:' + error.requestId + ')'
}
}
// 面向开发者的日志:完整上下文、方便排查
function logError(error: AppError, context: RequestContext) {
logger.error({
message: error.message,
code: error.code,
requestId: context.requestId,
userId: context.userId,
path: context.path,
queryParams: context.queryParams,
stack: error.stack,
timestamp: new Date().toISOString(),
})
}这两层的关注点不一样。用户需要知道「我该怎么办」,开发者需要知道「哪里出了问题」。把堆栈信息直接甩给用户,既不友好也不安全;只给用户一句「出错了」而不在日志里留足上下文,排查时就会抓瞎。
错误处理的决策流程
写错误处理代码时,我一般遵循下面这个决策流程:
关键节点有三个:第一,判断错误是否可恢复——可恢复的就给用户可操作的提示,不可恢复的要确保日志完整;第二,判断能否提供下一步建议——能提供的就写出来,不能提供的至少给个请求 ID;第三,检查是否有敏感信息——密钥、token、用户密码这些绝不能出现在日志或响应里。
这个流程看起来步骤多,但实际写代码时,大部分判断是在一瞬间完成的。关键是把「写错误信息之前先想清楚受众和目的」变成一种条件反射,而不是每次都等到线上出事了才回头补。
我见过一种反模式是:错误处理代码写了一大堆 if-else,每个分支返回不同的错误信息,但这些信息本身都是「操作失败」的变体。代码看起来很「完善」,但排查者看完依然不知道问题出在哪。错误处理的价值不在于覆盖了多少分支,而在于每条输出的信息能不能让下一个读它的人少走弯路。
常见错误信息模式对比
下面这张表汇总了我在代码审查中经常看到的反模式:
| 反模式 | 示例 | 问题 | 改进方向 |
|---|---|---|---|
| 万能错误 | Error: failed | 零信息量 | 写清动作和上下文 |
| 只说结果 | Invalid input | 不知道哪个字段 | 指出具体字段和期望格式 |
| 泄露内部 | NullPointerException at line 42 | 对用户无意义且暴露实现 | 用户看提示,开发者看日志 |
| 怪罪用户 | You entered the wrong format | 对抗性语气 | 中性描述 + 正确示例 |
| 吞掉错误 | catch(e) { /* 空 */ } | 错误消失,无法排查 | 至少记录日志 |
| 状态码滥用 | 返回 200 OK + { error: "..." } | 监控工具无法识别 | 使用正确的 HTTP 状态码 |
| 信息过载 | 把整个请求体塞进错误响应 | 响应膨胀、可能泄露数据 | 只记录关键上下文字段 |
对应的,好的错误信息应该长这样:
// ❌ 反模式:万能错误
throw new Error('failed')
// ✅ 改进:动作 + 目标 + 原因
throw new Error(`数据库查询失败 [表: articles, 条件: categoryId=${categoryId}] — 连接超时(30s),请检查数据库服务状态`)
// ❌ 反模式:只说结果
throw new Error('Invalid input')
// ✅ 改进:具体字段 + 期望格式
throw new Error(
`参数校验失败 [字段: publishDate] — ` +
`期望格式: ISO 8601 (如 2026-06-25),实际值: "tomorrow"`
)
// ❌ 反模式:泄露内部实现
res.status(500).json({ error: err.stack })
// ✅ 改进:分层响应
res.status(500).json({
type: 'https://api.example.com/errors/internal-error',
title: '服务器内部错误',
detail: '文章发布服务暂时不可用,请稍后重试',
requestId: context.requestId,
})
// 堆栈信息写入日志系统,不返回给客户端错误码设计:给机器一个识别入口
如果你的 API 有多个消费方(前端、移动端、第三方集成),纯文本错误信息不够用——他们需要程序化地判断错误类型并做相应处理。这时候错误码就有价值了。
错误码不一定要用数字,字符串形式可读性更好:
// 错误码定义 — 字符串格式,自文档化
const ErrorCodes = {
// 认证类
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
// 资源类
ARTICLE_NOT_FOUND: 'ARTICLE_NOT_FOUND',
ARTICLE_ALREADY_PUBLISHED: 'ARTICLE_ALREADY_PUBLISHED',
// 校验类
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
VALIDATION_FORMAT_INVALID: 'VALIDATION_FORMAT_INVALID',
// 限流类
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
} as const
// 消费方可以精确匹配
if (error.code === ErrorCodes.AUTH_TOKEN_EXPIRED) {
await refreshToken()
retry()
}对比一下不同错误码设计方式的优劣:
| 设计方式 | 示例 | 可读性 | 可扩展性 | 适用场景 |
|---|---|---|---|---|
| 纯数字 | 10001 | 差,需要查表 | 好,空间大 | 内部系统、性能敏感 |
| 字符串枚举 | AUTH_TOKEN_EXPIRED | 好,自文档化 | 中,需要维护常量 | API 对外错误码(推荐) |
| HTTP 状态码 | 401, 404 | 好,标准化 | 差,粒度粗 | REST API 粗分类 |
| 层级编码 | AUTH.001.TOKEN | 中 | 好,但复杂 | 大型系统、多团队 |
对多数项目来说,字符串枚举是个好平衡——既能被程序精确匹配,又能在日志里一眼看懂含义。
错误码的设计还有个容易踩的坑:枚举值不够稳定。如果你的错误码会在版本间变化,消费方每次升级 SDK 都得检查错误处理逻辑有没有受影响。所以在设计错误码时,应该把它当作 API 合约的一部分——新增可以,删除或重命名要谨慎,最好有文档说明每个错误码的含义和出现场景。
写错误信息前的检查清单
每次写错误处理代码,我都会过一遍这个清单。不一定每条都要满足,但至少大部分应该覆盖到:
- 描述了失败的动作 — 能看出是哪个操作出了问题,而不是一个笼统的「失败」
- 包含了关键上下文 — 文件路径、请求 ID、用户 ID、参数摘要等定位信息
- 说明了可能原因 — 不是猜,而是根据错误类型给出最可能的原因
- 提供了下一步建议 — 告诉排查者接下来该检查什么、改什么
- 区分了受众 — 用户看到的提示和开发者看到的日志是分开处理的
- 没有泄露敏感信息 — 密钥、token、密码、完整用户数据不在错误响应或日志中出现
- 使用了正确的 HTTP 状态码 — 错误场景返回 4xx/5xx,不用 200 伪装成功
- 错误码可被程序匹配 — 消费方能用
error.code做分支处理 - 错误信息可被搜索 — 日志中的错误信息是固定模板,方便 grep 和告警规则匹配
- 保留了原始错误对象 —
catch(e)后把e.stack传给日志系统,不只取e.message - 考虑了国际化 — 面向用户的错误提示通过 i18n 处理,不硬编码字符串
- 错误粒度合理 — 不过于笼统(「出错了」),也不过于琐碎(每行代码一个 try-catch)
- 没有吞掉错误 — 即使 catch 块不抛出,也至少记录了日志
- 有对应的监控告警 — 关键错误路径有告警规则,不只是靠人去翻日志
一些容易忽略的细节
错误信息的语言问题。如果你的项目面向国际用户,错误信息最好用英文写或者走 i18n 系统。我见过不少项目里中文和英文错误信息混在一起,排查时想 grep 都找不到完整结果。一个简单的做法是:日志里的错误信息统一用英文,面向用户的提示走翻译系统。
错误链的保留。JavaScript 的 Error 对象支持 cause 属性,可以把原始错误传递下去:
try {
await publishArticle(article)
} catch (err) {
throw new Error(
`发布文章失败 [articleId: ${article.id}] — 推送通知服务不可用`,
{ cause: err }
)
}
// 日志里可以看到完整的错误链:外层是「发布失败」,cause 里是「通知服务连接超时」这样不会丢失原始错误的堆栈和上下文,同时外层的错误信息对排查者更有指向性。
错误信息和日志级别的关系。很多项目里,所有错误都直接用 logger.error,结果日志系统里到处都是 ERROR 级别告警,真正的关键问题反而被淹没了。合理的做法是根据错误严重程度分级:
// 可预期的业务错误:用 warn,不需要告警
logger.warn({
message: '用户登录失败',
code: 'AUTH_INVALID_CREDENTIALS',
userId: user.id,
// 不记录密码等敏感字段
})
// 不可预期的系统错误:用 error,触发告警
logger.error({
message: '数据库连接池耗尽',
activeConnections: pool.size,
waitingRequests: pool.waiting,
lastQuery: sanitizeQuery(lastQuery),
})
// 调试信息:用 debug,开发环境才输出
logger.debug({
message: '文章查询参数',
filters,
pagination,
executionTime: Date.now() - startTime,
})错误信息和测试的关系。写单测时,可以针对错误信息的关键片段做断言——不是断言完整文案,而是断言关键信息是否包含:
await expect(loadMetaConfig('/nonexistent/path.json'))
.rejects.toThrow(/文件不存在/)
await expect(loadMetaConfig('/bad-format.json'))
.rejects.toThrow(/JSON 格式/)这种测试既不会因为文案微调就挂掉,又能确保错误信息的核心要素不丢失。
前端场景:表单校验和错误边界的写法
前端也有大量需要写错误信息的地方,表单校验是最常见的场景。很多人写表单校验提示时习惯用英文模板直接翻译,结果就是用户看到一句「请输入有效的值」——完全不知道到底该怎么填。
// ❌ 翻译腔,用户不知道具体要求
const validate = (value: string) => {
if (!value) return '此字段为必填项'
if (value.length < 6) return '输入长度不符合要求'
return ''
}
// ✅ 说清哪个字段、什么要求、给个示例
const validate = (value: string) => {
if (!value) return '密码不能为空'
if (value.length < 6) return '密码至少 6 个字符,当前输入了 ' + value.length + ' 个'
if (!/[A-Z]/.test(value)) return '密码需要包含至少一个大写字母'
return ''
}区别在于:改进后的提示告诉用户差在哪里、差多少、怎么改。尤其是「当前输入了 3 个」这种信息,看似微小,但能让用户立刻明白差距有多大。
React 的错误边界(Error Boundary)也是个值得注意的地方。默认的 fallback UI 如果只写一句「出错了」,对用户毫无帮助,对开发者也毫无线索:
// ❌ 默认 fallback:用户懵、开发者也懵
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return <h1>出错了</h1>
}
return this.props.children
}
}
// ✅ 改进:记录错误上下文、给用户可操作的反馈
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error
resetErrorBoundary: () => void
}) {
// 错误详情发送到日志系统
useEffect(() => {
logger.error({
message: error.message,
stack: error.stack,
url: window.location.href,
userAgent: navigator.userAgent,
})
}, [error])
return (
<div role="alert">
<h2>页面加载遇到了问题</h2>
<p>请尝试刷新页面,如果问题持续出现,请联系支持团队并提供以下编号:</p>
<code>{generateErrorId()}</code>
<button onClick={resetErrorBoundary}>重试</button>
</div>
)
}这里的关键是:用户看到的是可操作的提示(刷新、重试、联系支持),开发者通过日志系统拿到完整的错误堆栈和上下文。错误 ID 是两边的桥梁——用户报障时提供编号,开发者用编号在日志里精确定位。
错误信息写得好不好,本质上反映的是写代码的人有没有想过「半年后的自己怎么排查这个问题」。这不是什么高深的架构知识,就是一个习惯。养成这个习惯之后,你会发现自己凌晨被报警叫醒时,能少花很多时间在无意义的猜测上。
参考资料
Footnotes
-
Nielsen Norman Group, Error-Message Guidelines ↩