結論:Hono RPC の型推論が効く条件は2つだけ
Hono の hc クライアントで型が効かない原因のほぼすべては、次の2点に集約される。
export type AppType = typeof appを正しくエクスポートしていない- ルートを
.route()でつなぐ時、戻り値を使っていない
この2点さえ押さえれば、レスポンス型・リクエスト型・パスパラメーターの型が自動で推論されるようになる。
こんな症状が出たら設定を見直す
| 症状 | よくある原因 |
|---|---|
client.users.$get() で「プロパティが存在しない」型エラーになる |
AppType のエクスポートが間違っている |
レスポンスの型が unknown / Response になる |
c.json() ではなく Response.json() を使っている |
app.route() でサブルートを追加したら型が消えた |
.route() の戻り値を変数に代入していない |
リクエストボディの型が unknown になる |
zValidator の設定が間違っているかバージョン不一致 |
パスパラメーターの型が string でなく unknown になる |
ルートをチェーンせず app.get() を別行で呼んでいる |
原因1:AppType のエクスポートが間違っている
typeof を付け忘れている
Hono RPC で型を渡す時は「値」ではなく「型」をエクスポートする必要がある。typeof を忘れると型エラーになる。
// NG:値をそのまま型として使っている
const app = new Hono().get('/hello', (c) => c.json({ message: 'Hello' }))
export type AppType = app // エラー: 'app' refers to a value, but is being used as a type
// OK:typeof で型を取得する
const app = new Hono().get('/hello', (c) => c.json({ message: 'Hello' }))
export type AppType = typeof app
クライアント側での使い方:
import { hc } from 'hono/client'
import type { AppType } from './server' // type のみインポート
const client = hc<AppType>('http://localhost:3000')
// 完全に型が効く
const res = await client.hello.$get()
const data = await res.json()
// data の型は { message: string } と推論される
原因2:ルートをチェーンしていない
なぜチェーンしないと型推論が壊れるのか
Hono の型推論はメソッドチェーンで構築されたルートツリーを解析することで動作する。app.get() を別行で呼び出すと、TypeScript は追加されたルートの情報を app の型に反映できなくなる。
// NG:別行で追加すると型推論が壊れる
const app = new Hono()
app.get('/hello', (c) => c.json({ message: 'Hello' }))
app.post('/users', async (c) => {
const body = await c.req.json()
return c.json({ id: 1, name: body.name })
})
export type AppType = typeof app
// client.hello.$get() の戻り値が正しく推論されない
// OK:メソッドチェーンで書く
const app = new Hono()
.get('/hello', (c) => c.json({ message: 'Hello' }))
.post('/users', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 1, name: body.name })
})
export type AppType = typeof app
// 型推論が正しく動く
原因3:.route() でサブルートを追加した後に型が消える
よくある間違い:.route() の戻り値を無視している
.route() は 新しい型情報を含んだ新しいインスタンスを返す。元の app に対して破壊的にルートを追加するのではない。戻り値を使わないと型情報が失われる。
// NG:戻り値を無視している
const userRoutes = new Hono()
.get('/', (c) => c.json([{ id: 1, name: 'Alice' }]))
.post('/', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 2, ...body })
})
const app = new Hono()
app.route('/users', userRoutes) // 戻り値を使っていない!
export type AppType = typeof app // userRoutes の型情報が含まれない
// OK:.route() の戻り値を使う
const userRoutes = new Hono()
.get('/', (c) => c.json([{ id: 1, name: 'Alice' }]))
.post('/', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 2, ...body })
})
const app = new Hono().route('/users', userRoutes) // 戻り値を代入
export type AppType = typeof app
// クライアント側で型が効く
// client.users.$get() → { id: number; name: string }[] と推論される
複数のサブルーターを組み合わせるパターン
// routes/users.ts
import { Hono } from 'hono'
const users = new Hono()
.get('/', (c) => c.json([{ id: 1, name: 'Alice' }]))
.get('/:id', (c) => {
const id = c.req.param('id')
return c.json({ id: Number(id), name: 'Alice' })
})
.post('/', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 2, name: body.name }, 201)
})
export default users
// routes/posts.ts
import { Hono } from 'hono'
const posts = new Hono()
.get('/', (c) => c.json([{ id: 1, title: '記事タイトル' }]))
export default posts
// index.ts
import { Hono } from 'hono'
import users from './routes/users'
import posts from './routes/posts'
// チェーンで .route() をつなぐ
const app = new Hono()
.route('/users', users)
.route('/posts', posts)
export type AppType = typeof app
export default app
原因4:c.json() ではなく Response.json() を使っている
c.json() と Response.json() の型推論の違い
Hono の c.json() は型情報を保持する特別なメソッドだ。Web 標準の Response.json() や new Response() を使うと型情報が失われ、クライアント側で unknown になる。
// NG:Response.json() を使うと型が失われる
const app = new Hono()
.get('/hello', (c) => {
return Response.json({ message: 'Hello' }) // 型: Response(型情報なし)
})
// OK:c.json() を使う
const app = new Hono()
.get('/hello', (c) => {
return c.json({ message: 'Hello' }) // 型: TypedResponse<{ message: string }>
})
原因5:zValidator でリクエスト型が効かない
@hono/zod-validator の正しい使い方
リクエストボディやクエリパラメーターの型を RPC で引き継ぐには @hono/zod-validator の zValidator ミドルウェアを使う。
npm install @hono/zod-validator zod
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const app = new Hono()
.post(
'/users',
zValidator('json', createUserSchema), // 第1引数: 'json' | 'query' | 'param' | 'header' | 'form'
async (c) => {
const body = c.req.valid('json') // 型: { name: string; email: string }
return c.json({ id: 1, ...body }, 201)
}
)
export type AppType = typeof app
クライアント側でも型が効く:
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:3000')
// リクエストボディの型も推論される
const res = await client.users.$post({
json: { name: 'Alice', email: 'alice@example.com' }
// json の型が { name: string; email: string } と推論され、
// 間違った型を渡すとコンパイルエラーになる
})
const user = await res.json()
// user の型は { id: number; name: string; email: string } と推論される
バージョン不一致によるエラー
@hono/zod-validator と hono のバージョンが合わないと型推論が壊れることがある。
# バージョンを確認する
npm list hono @hono/zod-validator zod
{
"dependencies": {
"hono": "^4.0.0",
"@hono/zod-validator": "^0.4.0",
"zod": "^3.22.0"
}
}
ミドルウェアを噛ませると型が消える場合の対処
型を保持したままミドルウェアを適用する
app.use() でミドルウェアを追加すると、追加済みルートの型情報に影響が出ることがある。ミドルウェアはルート定義と別のインスタンスで管理するか、ファクトリ関数を使う方法が安全だ。
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
// ミドルウェアはチェーンの最初に置く
const app = new Hono()
.use('*', cors())
.use('*', logger())
.get('/hello', (c) => c.json({ message: 'Hello' }))
.post('/users', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 1, ...body })
})
export type AppType = typeof app
動作確認:型が正しく効いているかチェックする方法
TypeScript の型チェックで確認
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:3000')
// これで型が効いているか確認できる
async function main() {
const res = await client.hello.$get()
// VS Code や tsc でホバーして型を確認する
// 正常: { message: string }
// 異常: unknown または Response
const data = await res.json()
console.log(data.message) // 型エラーが出ない → 型が効いている
}
# 型チェックを実行して確認
npx tsc --noEmit
最小構成のサンプル(コピペで動く)
サーバー側(Cloudflare Workers / Bun / Node.js 共通)
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
.get('/hello', (c) => {
return c.json({ message: 'Hello, Hono RPC!' })
})
.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ id: Number(id), name: 'Alice' })
})
.post('/users', async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 2, name: body.name }, 201)
})
export type AppType = typeof app
export default app
クライアント側
// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './index'
const client = hc<AppType>('http://localhost:8787')
async function main() {
// GET /hello → { message: string }
const helloRes = await client.hello.$get()
const hello = await helloRes.json()
console.log(hello.message) // 'Hello, Hono RPC!'
// GET /users/:id → { id: number; name: string }
const userRes = await client.users[':id'].$get({ param: { id: '1' } })
const user = await userRes.json()
console.log(user.id, user.name) // 1, 'Alice'
// POST /users
const createRes = await client.users.$post({ json: { name: 'Bob' } })
const newUser = await createRes.json()
console.log(newUser.id, newUser.name) // 2, 'Bob'
}
main()
まとめ
export type AppType = typeof appのtypeofを絶対に忘れない。これが抜けると型が一切効かない。- ルートは必ずメソッドチェーンで書く。
app.get()を別行で呼ぶと型推論が壊れる場合がある。 .route()の戻り値を使う。戻り値を無視すると型情報が失われる。c.json()を使う。Web 標準のResponse.json()を使うとレスポンス型がunknownになる。- リクエストの型付けは
zValidatorを使う。c.req.json<T>()でも動くが、クライアント側への型伝播はzValidator経由の方が確実。 - Hono RPC は正しく設定すれば、サーバーとクライアント間で型を完全に共有できる強力な機能なので、詰まっても上記のチェックリストで解決できるはずだ。
