这次问题是从一个很普通的 400 Bad Request 开始的。
服务端日志里有请求路径、SQL 和耗时,最后一行只告诉我接口返回了 400。客户端也弹了错误,但弹窗没有说清楚是哪条业务规则没通过。过一会儿,另一个页面又冒出一句英文错误。应用明明是中文的,错误链却像是几套临时逻辑拼在一起。
我最初只想补一条错误提示,查下去才发现问题不在某个 Toast。服务端怎样返回错误、客户端怎样解包、页面怎样判断后续动作、线上日志记录什么,这几件事没有形成一条稳定的链。
我先把文案和错误码分开
接口错误里最容易混淆的是 error 和 code。
{
"error": "请设置地址",
"code": "ADDRESS_REQUIRED"
}error 给用户看,所以它应该是简短、明确的中文。code 给客户端判断流程,不应该直接显示。
例如客户端收到 ADDRESS_REQUIRED 后,可以打开地址设置提示;收到普通的参数错误,只需要展示服务端文案。页面不应该靠比较中文句子决定行为:
if (requestError.message === "请设置地址") {
// 不要这样判断
}文案会调整,也可能因为产品语言变化而重写。机器标识一旦进入接口契约,就应该保持稳定。
反过来也一样。把 ADDRESS_REQUIRED 直接塞进 Toast,技术上省了一步,用户看到的却是内部协议。因此错误码不能拿来当备用文案。
两端各写一份以后,我还是觉得不对
我一开始在服务端和客户端各定义了一份错误码:
export const ApiErrorCode = {
Unauthorized: "UNAUTHORIZED",
AddressRequired: "ADDRESS_REQUIRED",
} as const;类型检查都能通过,看起来也比散落的字符串好。问题是两份常量没有联系。某天服务端改了名字,客户端仍然可以带着旧值正常编译。
既然代码在同一个 workspace 中,更直接的办法是让服务端 package 暴露一个不依赖数据库、运行时框架和环境变量的 error.ts:
export const ApiError = {
Unauthorized: {
error: "请重新登录",
code: "UNAUTHORIZED",
},
AddressRequired: {
error: "请设置地址",
code: "ADDRESS_REQUIRED",
},
} as const;
export type ApiErrorCode =
(typeof ApiError)[keyof typeof ApiError]["code"];服务端可以直接返回完整错误:
return context.json(ApiError.AddressRequired, 403);客户端只读取同一个对象:
import { ApiError } from "server/error";
if (requestError.code === ApiError.AddressRequired.code) {
openAddressDialog();
}这样文案和错误码不会分开漂移,客户端也不需要维护一份镜像。
这里有一条边界:error.ts 只能放跨端需要的稳定协议。不要从里面导入数据库对象、日志器或服务端配置,否则一次普通的前端导入可能把后端依赖拖进打包器。
RequestError 也应该认识这些错误码
共享错误对象以后,客户端的错误类型也应该使用推导出来的联合类型:
export class RequestError extends Error {
readonly issues: RequestIssue[];
readonly code?: ApiErrorCode;
constructor(
message: string,
issues: RequestIssue[] = [],
code?: ApiErrorCode,
) {
super(message);
this.name = "RequestError";
this.issues = issues;
this.code = code;
}
}如果这里写成 code?: string,共享类型只用了一半。页面仍然可以拿任意拼错的字符串比较,编辑器也不会提醒。
不过 HTTP 响应来自运行时,TypeScript 不能保证服务器、网关或缓存一定返回声明中的值。解析响应时仍要检查一次:
export function isApiErrorCode(value: unknown): value is ApiErrorCode {
return Object.values(ApiError).some((item) => item.code === value);
}这段校验只负责把不可信的 JSON 收窄成本地联合类型,与表单验证和业务规则无关。
我把错误展示收回到一个入口
另一个混乱来源是每一层都想“顺便提示一下”。请求封装弹一次,调用它的 Hook 弹一次,页面 catch 后再弹一次。一个失败请求最后出现两三个 Toast,或者上层为了防止重复提示,干脆把错误吞掉。
我最后保留的链条很短:
HTTP 响应
-> 请求解包函数读取 error、code、errors
-> 构造 RequestError
-> 统一展示服务端 error
-> 页面只处理确实需要分支的 code页面捕获 RequestError 时,默认不再重复展示。只有上传文件、读取本地缓存这类没有经过请求解包函数的异常,页面才补一个兜底提示。
参数校验错误可以额外带字段列表:
{
"error": "标题不能为空",
"code": "VALIDATION_FAILED",
"errors": [
{
"field": "title",
"message": "标题不能为空"
}
]
}通用页面展示第一条中文错误。表单如果需要定位控件,再读取 errors。客户端不重新判断长度、范围和业务关系,这些规则仍由服务端负责。
业务错误没有全部搬进 error.ts
建立 error.ts 后,很容易继续走到另一个极端:把所有错误文案都搬进去。
export const EverythingThatCanGoWrong = {
ItemNotFound: "内容不存在",
ItemCannotMove: "当前不可移动",
ItemCannotDelete: "当前不可删除",
// 后面还有几百项
};这种文件看起来统一,实际会让业务判断和错误原因分离。修改状态机时,要在路由和错误表之间来回跳,还要想办法给相似错误命名。
适合集中的是跨越多个入口、客户端需要识别的固定错误,例如未登录、角色不足、缺少必要资料、参数验证失败和路由不存在。只在某条业务分支出现的错误,留在判断旁边反而更清楚:
if (record.status !== "PENDING") {
return context.json({ error: "当前不可修改" }, 400);
}需要统一的是错误结构和展示出口。业务文案仍然贴着对应判断写。
六层三元表达式被我换成了 switch
全局异常处理还需要把没有业务文案的 HTTPException 转成中文。这个逻辑很简单,用 switch 就够了:
export function getHttpErrorMessage(status: number) {
switch (status) {
case 400:
return "参数错误";
case 401:
return "请重新登录";
case 403:
return "无权操作";
case 404:
return "内容不存在";
case 409:
return "状态已变化";
case 429:
return "请求过于频繁";
default:
return status >= 500 ? "服务器错误" : "请求失败";
}
}我之前见过把同一段逻辑写成六层三元表达式。代码行数少了一点,读的时候却要从左到右维护一棵条件树。错误兜底本来就会在排查问题时反复阅读,没有必要省这几行。
全局错误处理也不能把异常对象的 message 原样发给客户端。数据库错误、对象存储响应和第三方 SDK 异常都可能带英文、内部地址甚至实现细节。服务端日志保存原异常,接口只返回受控的中文文案。
线上我仍然要看到 400
错误响应整理完,日志策略也要跟上。
生产环境保留访问日志是有用的:
[2026-08-11 16:46:15.024 Asia/Shanghai] <-- GET /resource/3
[2026-08-11 16:46:15.028 Asia/Shanghai] --> GET /resource/3 200 4ms它能回答请求有没有到达、状态码是什么、耗时多久。400 和 403 也应该记录,因为它们经常对应真实的业务失败,不是可以忽略的“客户端问题”。
但开发期的 SQL、缓存细节和每分钟执行一次的调度器心跳,不应该原样进入生产日志。比较实用的分层是:
- 访问日志始终保留。
error始终保留,400 以上响应记录受控中文错误。debug只在开发环境开启,用来打印 SQL、任务细节和临时诊断信息。
时间也要统一。容器设置 TZ 并不保证所有日志库都按本地时区格式化,尤其是代码直接调用 toISOString() 时,它永远输出 UTC。日志器应明确读取时区并在时间戳中写出来,不要让排查者自己加八小时。
我以为 import type 不会再读取服务端
整理跨端错误时,我顺手重新看了一遍 Hono RPC 类型。服务端通常会导出:
export type AppType = typeof app;客户端再交给 hc:
import type { AppType } from "server/app-type";
const client = hc<AppType>(baseUrl);我实际改成直接导入以后,客户端类型检查马上开始读取服务端路由。import type 确实会在运行时代码中消失,但 TypeScript 为了算出 typeof app,仍然要继续读取路由链、校验器和响应类型。服务端源码里的路径别名、Bun 类型和数据库驱动声明也跟着进来了。
这也是“直接导入类型”和“导入编译好的声明”之间的区别。前者不产生运行时代码,却仍然展开源码;后者读取的是已经计算好的 .d.ts。
我原来怎样生成 Hono RPC 类型
直接导入没有我想得那么轻,我又回头看原来的类型生成命令。这里一直用的是 dts-bundle-generator,它从 Hono 入口生成一个文件:
dts-bundle-generator \
-o ../client/src/generated/hono-with-types.d.ts \
src/index.ts \
--no-checksrc/index.ts 是声明入口,-o 后面是客户端使用的生成文件。工具会沿着 AppType 展开本地类型,再把结果合并到这个 .d.ts。客户端读取的是已经算好的声明,不再碰服务端源码。
我之前把它和 Hono RPC 混在一起理解了。Hono 只负责从 typeof app 推导接口类型,dts-bundle-generator 负责把这份类型整理成一个可以交给客户端的文件。换掉生成器不会改变 Hono 的类型推导,只会改变声明怎样输出。
重新看命令时,--no-check 让我有点不放心。这个参数会跳过生成声明的最终校验。现在客户端类型检查紧跟在生成命令后面,所以错误还能被下一步拦住;我还是更倾向于先试着删掉它,而不是长期依赖后续检查兜底。
我为什么暂时没有换成 tsc
看到项目里多了一个声明生成器,我第一反应是改回 TypeScript 原生的声明构建:
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "./dist/types"
}
}但 tsc 默认按源码模块生成一组声明文件。routes/example.ts、types.ts 和 index.ts 会有各自的 .d.ts,文件之间继续通过 import 关联。这要求服务端先有清楚的 package 出口,内部导入也必须能被客户端解析。还要补上构建顺序:先生成服务端声明,再检查客户端。
dts-bundle-generator 没有把整棵声明目录交给客户端。它从入口展开本地类型,只留下入口引用的部分,最后得到一份单文件 RPC 类型。服务端还有不少内部模块和路径别名时,这种隔离反而省事。
所以我暂时没有换。以后如果把服务端整理成真正的 TypeScript package,我会再用 tsc 生成声明目录,并把构建顺序交给 workspace 管理。现在只是把 Hono RPC 类型交给客户端,单文件声明更直接。
共享错误对象还有一个看得见的副作用。对象使用 as const 后,Hono 生成的响应声明会保留只读属性、错误码字面量,甚至中文文案字面量。类型更精确了,生成文件的 diff 也会明显变大。这不影响运行,但代码审查时要确认变化来自类型收窄,而不是接口突然增加了几十种响应。
无论选择哪种方式,生成后的声明都要交给客户端类型检查。声明文件本身也是接口产物,不能只确认命令退出码。
最后我检查了什么
错误处理很难靠一次手工点击覆盖。我会固定检查下面几件事:
- 扫描服务端直接返回的错误文案,确认没有英文漏到客户端。
- 测试常用 HTTP 状态码都映射成预期中文。
- 验证已声明错误码能通过类型守卫,未知字符串会被拒绝。
- 重新生成 RPC 声明,再运行客户端类型检查。
- 用浏览器目标打包一次共享
error.ts,确认 package 导出不是只在编辑器里可见。
日志还要单独测生产配置。访问日志应保留,调试日志应关闭,400 响应和未处理异常仍然要出现。时间戳则用一个固定时间做断言,避免测试只检查“看起来像时间”。
这次整理以后,我对错误处理的要求少了很多。服务端给出准确的中文原因,code 只处理稳定分支;客户端只有一个解包入口,页面不重复弹错。共享文件放跨端协议,业务错误仍留在判断现场。
以后增加错误时,我只需要先判断它是不是跨端流程的一部分。不是的话,就近返回中文文案即可。这样比继续扩充错误框架更容易维护。