← 記事一覧に戻る

Hono RPC(hc クライアント)で型が効かない・型エラーになる時の原因と対処法【AppType / .route() チェーン / zValidator】

結論:Hono RPC の型推論が効く条件は2つだけ

Hono の hc クライアントで型が効かない原因のほぼすべては、次の2点に集約される。

  1. export type AppType = typeof app を正しくエクスポートしていない
  2. ルートを .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-validatorzValidator ミドルウェアを使う。

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-validatorhono のバージョンが合わないと型推論が壊れることがある。

# バージョンを確認する
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 apptypeof を絶対に忘れない。これが抜けると型が一切効かない。
  • ルートは必ずメソッドチェーンで書くapp.get() を別行で呼ぶと型推論が壊れる場合がある。
  • .route() の戻り値を使う。戻り値を無視すると型情報が失われる。
  • c.json() を使う。Web 標準の Response.json() を使うとレスポンス型が unknown になる。
  • リクエストの型付けは zValidator を使う。c.req.json<T>() でも動くが、クライアント側への型伝播は zValidator 経由の方が確実。
  • Hono RPC は正しく設定すれば、サーバーとクライアント間で型を完全に共有できる強力な機能なので、詰まっても上記のチェックリストで解決できるはずだ。