〜 v4 と並べて、何が壊れるのか確かめた 〜
田中博悠 / tanahiro2010

Hono v5 RC、出ましたね
5.0.0-rc.05.0.0-rc.1honojs/hono releases
npm i hono@nextもう触れます
で
あなたのアプリ、
v5 にしたら壊れますか?
⋯
僕のは壊れました
方針
Migration Guide の翻訳はしない
「ドキュメントにこう書いてある」
↓
「実際にこうなった」
| v4 | [email protected] |
| v5 | [email protected] |
| Node.js | v24.22.0 |
| TypeScript | 7.0.2 |
Apple M4 / 2026-10-18 時点
型
c.req.json()app.post('/users', async (c) => {
const body = await c.req.json()
return c.json({ name: body.name.toUpperCase() })
})anyunknownv4 では、このコードが通る
error TS18046: 'body' is of type 'unknown'.ちゃんと止まった
もうひとつ気になってたのが
c.req.query()const { name } = c.req.query()
const upper: string = name.toUpperCase()Record<string, string>Record<string, string | undefined>これ、分割代入してるコードが
全部赤くなるのでは?
error TS18048: 'name' is possibly 'undefined'.
error TS2322: Type 'string[] | undefined' is not
assignable to type 'string[]'.全部赤くなった
でも、ここで面白いことに気づいた
GET /q?a=1&a=2
| v4 | v5 | |
|---|---|---|
query() | {"a":"1"} | {"a":"1"} |
queries() | {"a":["1","2"]} | {"a":["1","2"]} |
完全に同じ
undefined は返っていた赤くなった箇所は、
v4 の時点でバグり得た箇所
エラー
ここが v5 で一番面白い
onError()app.onError((err, c) => {
console.error(err.message)
return c.json({ error: 'Internal Server Error' }, 500)
})(err, c)onError()app.onError('/api/*', async (c, next) => {
console.error(c.error?.message)
c.header('X-Error-Handler', 'api')
await next()
})
app.onError((c) => c.json({ error: 'Error' }, 500))(c, next) → 普通のミドルウェアc.error から読むエラー処理を Hono のルーティングシステムの上に
モデル化する
@ERROR / @NOT_FOUND という予約メソッドで Router に登録されている
honojs/hono#5541
本当にパスで絞れるのか?
| リクエスト | ヘッダ | status |
|---|---|---|
/api/error | api | 500 |
/other/error | なし | 500 |
効いた
ちなみに v4 で同じことを書くと
this.errorHandler is not a functionここからが本番
onError() を 2回登録したら
どうなる?
app.onError((c) => { order.push('first'); ... })
app.onError((c) => { order.push('second'); ... })| 実行されたハンドラ | |
|---|---|
| v4 | ["second"] |
| v5 | ["first", "second"] |
v4 は上書き
v5 は合成
型エラーにならない
シグネチャさえ直せば tsc は通る
じゃあ v4 の書き方のまま
動かしたらどうなる?
error TS2339: Property 'message' does not exist
on type 'Context<BlankEnv, string, any>'.
error TS2339: Property 'json' does not exist
on type 'Next'.Context、第2引数が Next問題は、これを握りつぶしたとき
Context is not finalized.
Did you forget to return a Response object
or `await next()`?原因を指していない
c のつもりで触ってるものが next 関数c.text() が無い → 例外型エラーを消すと、デバッグが遠回りになる
throw したら| v4 | v5 | |
|---|---|---|
throw 'oops' | 外に漏れる | message / cause に 'oops' |
throw { code: 42 } | 外に漏れる | message は 空 |
cause にしか入らないc.error.message だけログに出すと何も分からない地雷
型でも捕まらないやつ
c.json(undefined)| v4 | v5 | |
|---|---|---|
c.json(undefined) | 200 / 空ボディ | 500 |
c.json(() => 1) | 200 / 空 | 500 |
TypeError: Value is not JSON serializable型エラーにならない
const user = await db.query.users.findFirst({ ... })
return c.json(user) // 見つからなければ undefinedつまり
v5 で 500 が出る箇所は
v4 の時点でバグっていた箇所
ルーター
ワイルドカードが統一された
v4 は Router ごとにバラバラだった
| ルート | パス | Smart | RegExp | Trie | Pattern |
|---|---|---|---|---|---|
/x*/y | /x/y | ❌ | ❌ | ❌ | ❌ |
/x*/y | /xz/y | ✅ | ✅ | ❌ | ❌ |
/x*y | /xay | ✅ | ✅ | ❌ | ❌ |
/wild/*/card | /wild//card | ❌ | ❌ | ✅ | ❌ |
6ケース中3ケースで割れている
| ルート | パス | Smart | RegExp | Trie | Pattern |
|---|---|---|---|---|---|
/x*/y | /x/y | ✅ | ✅ | ✅ | ✅ |
/x*/y | /xz/y | ✅ | ✅ | ✅ | ✅ |
/x*y | /xay | ❌ | ❌ | ❌ | ❌ |
/wild/*/card | /wild//card | ❌ | ❌ | ❌ | ❌ |
4つとも一致
ここが一番おいしい
app.get('/x*/y', (c) => c.text('a'))
app.get('/x/*', (c) => c.text('b'))UnsupportedPathError: /x/*じゃあ SmartRouter はどうなる?
| 登録したルート | Router |
|---|---|
/x/* だけ | SmartRouter + RegExpRouter |
/x*/y + /x/* | SmartRouter + TrieRouter |
ルートを1行足しただけで
Router が入れ替わった
性能も変わるのでは?
GET /users/:id × 50,000回 × 12ラウンド
| 中央値 | 最小 | |
|---|---|---|
| RegExpRouter | 138.68 ms | 132.35 ms |
| Trie フォールバック | 148.57 ms | 146.25 ms |
| 差 | +7.1% | +10.5% |
遅くなった
しかも
同じ競合ルートに /x/y を投げると
| v4 | v5 |
|---|---|
/x/* の方 | /x*/y の方 |
性能
v5、速くなったの?
GET /users/:id × 100,000回 × 10ラウンド
| 中央値 | 最小 | |
|---|---|---|
| v4 | 270.53 ms | 258.31 ms |
| v5 | 266.50 ms | 255.59 ms |
app.fetch() のインプロセス計測。実サーバーの性能ではない
誤差
ここで、恥ずかしい話を
ラウンド数を変えただけで
結論が倍違った
ラウンド数と分布を出す
3.3% → 7.1% に変わった
コアの性能は据え置き
v5 の変更は、性能のためじゃない
サイズ
「アダプタ分離で軽くなった」
本当?
| 展開後 | ファイル数 | |
|---|---|---|
| v4 | 1.23 MB | 574 |
| v5 | 0.66 MB | 310 |
npm registry の unpackedSize
-46%
du -sh(2.8M → 1.5M)→ -46%ファイル数 574 → 310 は完全一致
じゃあバンドルは?
| v4 | v5 | 差 | |
|---|---|---|---|
| コアのみ | 18,528 B | 18,830 B | +302 B |
| アダプタ込み | 22,760 B | 22,923 B | +163 B |
増えた
@hono/bun を入れれば同じコードが入る「軽くなった」は
インストールサイズの話
設計
ここまで全部、同じ方向を向いてる
抽象化されていて、選択肢が少ない
Yusuke Wada / Hono Conference 2026
any だけど大丈夫」と空気を読むそれが一番出てるのが
defineHandler()app.get('/ping', defineHandler(() => ({ pong: true })))ping -> 200 {"pong":true}c.json() を呼んでいないapp.post('/users', defineHandler({ json: NameSchema })(
(c, input) => ({ name: input.json.name.toUpperCase() })
))400 {"error":"Validation failed","issues":[...]}throw すると400 ではなく 500 になる
A JSON Response by
c.json()is not accepted,
because it bypasses the response validation.
c.json() を返すことを型で禁止しているやり方が複数あって、どれでも動く
↓
正しいやり方が1つで、他は型で止まる
まとめ
①まず tsc --strict を通す
c.req.json() の直アクセスc.req.query() の分割代入onError((err, c) => ...)→ 機械的に潰せる
②型では捕まらない3つ
c.json(undefined) → 500onError() の合成化 → 両方走る→ ここが本番
③import を書き換える
hono/<runtime> → @hono/<runtime>hono/cloudflare-pages → 代替なしapp.mount() → hono/mountapp.fire() → @hono/service-workerc.json(undefined) が 500v5 で壊れるのは
v4 が緩かったところ
壊れたんじゃなくて、
隠れてたバグが出てきただけ
本番投入は RC なので待ちます
でも hono@next で1回ビルドするのは
今やる価値がある
型エラーの量を見れば、
移行コストがその場で分かる
ありがとうございました
One more thing
正式リリースまでの予想
defineHandler() だと思う
onError() とワイルドカードはこのまま出るhono/cloudflare-pages の削除
c.req.query() が圧倒的外れるかもしれません
正式版が出たら、答え合わせします
npm i hono@next