1. 不做错误处理会怎样
先看一个没有任何错误处理的路由:
1app.get('/users/:id', (c) => {2const user = findUser(c.req.param('id'))3// findUser 返回 undefined,直接访问 .name 会炸4return c.json({ name: user.name })5})
当 findUser 返回 undefined 时,代码抛出 TypeError: Cannot read properties of undefined。Hono 默认会返回一个 500 状态码的纯文本响应:
1Internal Server Error
前端拿到的就是这么一行字。没有 JSON,没有错误码,没有任何有用信息。前端开发者没法根据这个做任何有意义的错误提示。
这里先建立一个最重要的判断:
- 能正常返回结果:用
return c.json(...) - 遇到异常情况,需要中断当前流程:用
throw
也就是说,错误处理这篇文章讨论的核心,不是"怎么返回数据",而是"当流程不能继续时,怎么把错误用一种统一、可控的方式抛出去"。
2. HTTPException:Hono 内置的错误类
Hono 提供了 HTTPException,用来抛出带状态码的 HTTP 错误:
01import { Hono } from 'hono'02import { HTTPException } from 'hono/http-exception'0304const app = new Hono()0506app.get('/users/:id', (c) => {07const user = findUser(c.req.param('id'))08if (!user) {09throw new HTTPException(404, { message: 'User not found' })10}11return c.json(user)12})
抛出 HTTPException 后,Hono 会把它当成一个"带 HTTP 状态码的错误"来处理。最基础的情况下,它可以直接生成对应状态码的响应。比起让代码自然报错变成 500,这种方式能精确告诉前端「到底出了什么问题」。
HTTPException 的构造函数接收两个参数:
- 第一个是 HTTP 状态码(
400、401、403、404、500等) - 第二个是选项对象,
message字段是错误描述
对新手来说,可以先这样理解:HTTPException 就是普通 Error 的 HTTP 版本。普通 Error 只会说"出错了",HTTPException 还会顺便告诉框架"这次应该返回 404 还是 401"。
3. app.onError:全局错误处理器
每个路由里都写 try-catch 太啰嗦。用 app.onError 注册一个全局错误处理器,所有路由里抛出的错误都会走到这里:
01import { Hono } from 'hono'02import { HTTPException } from 'hono/http-exception'0304const app = new Hono()0506app.onError((err, c) => {07// HTTPException:业务主动抛出的错误,返回对应状态码08if (err instanceof HTTPException) {09return c.json(10{ success: false, error: err.message },11err.status12)13}1415// 其他错误:代码 bug 或未预期的异常16console.error(err)17return c.json(18{ success: false, error: 'Internal Server Error' },1950020)21})
这里顺手补一个认知:app.onError 只会处理抛出来的错误。也就是说:
throw new HTTPException(...)会进入app.onErrorreturn c.json(...)不会进入app.onError,因为那已经是一次正常响应了
这也是为什么很多项目会把"业务失败"统一写成 throw,这样所有错误都能汇总到一个地方处理。
这样做的好处:
- 所有错误响应格式统一,前端只需要处理一种结构
HTTPException和普通异常分开处理,业务错误返回具体信息,代码 bug 返回通用提示(不暴露内部细节)- 路由处理函数里只管抛错,不用操心响应格式
4. app.notFound:自定义 404 响应
当请求的路径没有匹配到任何路由时,Hono 默认返回 404 Not Found 纯文本。用 app.notFound 可以自定义这个响应:
01app.notFound((c) => {02return c.json(03{04success: false,05error: 'Not Found',06path: c.req.path,07},0840409)10})
注意 app.notFound 和 HTTPException(404) 的区别:
app.notFound:路径压根没匹配到路由,请求连路由处理函数都没进HTTPException(404):路径匹配到了路由,但业务逻辑判断资源不存在(比如用户 ID 在数据库里查不到)
另外要注意,app.notFound 是给顶层 app 兜底用的。你可以把它理解成"整个应用最后的 404 出口",而不是某个单独路由模块内部的业务判断逻辑。
5. 自定义错误类
实际项目中,光靠 HTTPException 不够细。你可能需要区分认证失败、参数校验失败、权限不足等不同类型的业务错误。
可以继承 HTTPException 做分类:
01import { HTTPException } from 'hono/http-exception'0203// 认证错误04export class AuthError extends HTTPException {05constructor(message = 'Unauthorized') {06super(401, { message })07}08}0910// 参数校验错误11export class ValidationError extends HTTPException {12public details: Record<string, string[]>1314constructor(15message: string,16details: Record<string, string[]> = {}17) {18super(400, { message })19this.details = details20}21}2223// 权限不足24export class ForbiddenError extends HTTPException {25constructor(message = 'Forbidden') {26super(403, { message })27}28}
路由里使用:
01import { AuthError, ValidationError } from './errors'0203app.post('/articles', async (c) => {04const token = c.req.header('Authorization')05if (!token) {06throw new AuthError('Missing token')07}0809const body = await c.req.json()10if (!body.title) {11throw new ValidationError('Validation failed', {12title: ['title is required'],13})14}1516// 正常业务逻辑...17return c.json({ success: true, data: body }, 201)18})
然后在全局错误处理器里针对不同类型做处理:
01app.onError((err, c) => {02if (err instanceof ValidationError) {03return c.json(04{05success: false,06error: err.message,07code: 'VALIDATION_ERROR',08details: err.details,09},10err.status11)12}1314if (err instanceof HTTPException) {15return c.json(16{ success: false, error: err.message },17err.status18)19}2021console.error(err)22return c.json(23{ success: false, error: 'Internal Server Error' },2450025)26})
注意 ValidationError 的判断要放在 HTTPException 前面,因为 ValidationError 继承自 HTTPException,反过来写的话 instanceof HTTPException 会先匹配到。
6. 统一错误响应格式
把错误响应格式定义清楚,前后端约定好:
01// 统一错误响应结构02interface ErrorResponse {03success: false04error: string05code?: string06details?: Record<string, string[]>07}0809// 统一成功响应结构10interface SuccessResponse<T> {11success: true12data: T13}
封装一个工具函数,让响应构造更简洁:
01import type { Context } from 'hono'0203export const success = <T>(c: Context, data: T, status = 200) => {04return c.json({ success: true, data }, status)05}0607export const error = (08c: Context,09message: string,10status = 400,11code?: string12) => {13return c.json({ success: false, error: message, code }, status)14}
这里用普通 number 做状态码参数就够用了。对新手教学来说,这样更直观,也避免一上来引入额外的类型细节。
路由里用起来就很清爽:
1import { success } from './response'23app.get('/users/:id', (c) => {4const user = findUser(c.req.param('id'))5if (!user) {6throw new HTTPException(404, { message: 'User not found' })7}8return success(c, user)9})
7. 和 zValidator 校验错误的配合
如果你用了 @hono/zod-validator 做请求校验(前面章节介绍过),校验失败默认返回 400 状态码和校验错误详情。但默认格式可能跟你的统一格式不一致。
可以通过 hook 参数把校验失败也接入统一的错误处理:
01import { zValidator } from '@hono/zod-validator'02import { z } from 'zod'03import { HTTPException } from 'hono/http-exception'0405const createUserSchema = z.object({06name: z.string().min(1, 'name is required'),07email: z.string().email('invalid email'),08})0910app.post(11'/users',12zValidator('json', createUserSchema, (result, c) => {13if (!result.success) {14// 校验失败,抛 HTTPException 走全局错误处理15throw new HTTPException(400, {16message: 'Validation failed',17})18}19}),20(c) => {21const data = c.req.valid('json')22return c.json({ success: true, data }, 201)23}24)
zValidator 的第三个参数是一个 hook 函数,在校验完成后调用。result.success 为 false 时表示校验失败,这时抛出 HTTPException 就能走到 app.onError,保持所有错误响应格式一致。
这个设计背后的思路很值得记住:谁发现错误,谁可以先把错误整理好,再统一抛给全局错误处理器。
这样路由本身就不用关心"校验失败时响应长什么样",它只关心"数据已经合法了,可以继续写业务逻辑"。
如果你想带上具体的字段错误信息,可以用前面定义的 ValidationError:
01import { ValidationError } from './errors'0203app.post(04'/users',05zValidator('json', createUserSchema, (result, c) => {06if (!result.success) {07const details: Record<string, string[]> = {}08result.error.issues.forEach((issue) => {09const key = issue.path.join('.')10if (!details[key]) details[key] = []11details[key].push(issue.message)12})13throw new ValidationError('Validation failed', details)14}15}),16(c) => {17const data = c.req.valid('json')18return c.json({ success: true, data }, 201)19}20)
这样前端收到的校验错误响应长这样:
1{2"success": false,3"error": "Validation failed",4"code": "VALIDATION_ERROR",5"details": {6"name": ["name is required"],7"email": ["invalid email"]8}9}
前端可以直接把 details 里的信息映射到对应的表单字段上,体验非常好。
8. 总结
错误处理的核心就三件事:用 HTTPException 抛业务错误,用 app.onError 统一捕获,用 app.notFound 处理 404。在此基础上,自定义错误类做分类,统一响应格式让前端好对接。
下一篇聊认证与鉴权——怎么用中间件实现 JWT 验证和权限控制。