はじめに
@hono/zod-openapiでcreateRoute()のresponsesにZodスキーマを定義していると、「レスポンスの形はこのスキーマで保証されている」ような安心感がありますよね。
しかし、実際にDBの生カラムがAPIレスポンスに漏れる事故を起こして調べたところ、responsesスキーマはOpenAPIドキュメント生成とTypeScriptの型推論にしか使われておらず、ランタイムでは一切検証されていないことが分かりました。リクエスト側のc.req.valid()と対になる仕組みは、レスポンス側にはデフォルトで存在しません。
この記事では、
- 実際に起きた事故の内容
- なぜ
responsesスキーマを定義していたのに防げなかったのか - どう対策すればいいか(
satisfies、さらに強くするヘルパー関数+実行時parse) - (余談)Fastifyは同じ問題にどう対処しているか
の順にまとめます。個人開発中のnote統計ダッシュボードアプリ「Advanced Dashboard for note」のAPI(Hono + Zod + Drizzle + Cloudflare Workers構成)で実際に遭遇した話がベースです。
事故の内容
createStatsHandlerというエンドポイントで、DrizzleのINSERT ... RETURNINGの戻り値をそのままc.json()に渡していました。
const newStats = await createStats(db, statsData)
return c.json({
article: { key: article.key, title: article.title, publishedAt: article.publishedAt },
stats: newStats // ← DBの全カラム(id, articleIdを含む)がそのまま漏れる
})
CreateStatsResponseSchemaのstatsにはreadCount / likeCount / commentCount / fetchedAtしか定義しておらず、idやarticleIdは含めていません。しかし実際のレスポンスにはid/articleIdが漏れて返っていました。
スキーマを定義していたのに、なぜ防げなかったのでしょうか。
なぜ防げなかったか
1. responsesスキーマはランタイムでは検証されない
createRoute()のresponses[...].content['application/json'].schemaは、
- OpenAPIドキュメント生成
- ハンドラ戻り値のTypeScript型推論
にのみ使われ、実際のレスポンスボディをパース/検証する処理ではありません。
c.json()は渡されたオブジェクトをそのままJSON.stringifyしているだけです。
リクエスト側はc.req.valid()で検証されますが、レスポンス側に相当する仕組みはデフォルトでは存在しません。
これは@hono/zod-openapiのREADMEを読んでも確認できます。検証の話が出てくるのはdefaultHook/ErrorSchemaのセクションだけですが、これはリクエスト側のバリデーションエラー時のレスポンス型を扱う仕組みで、正常系のレスポンス検証とは無関係でした。
リクエスト (request.body等) |
レスポンス (responses) |
|
|---|---|---|
| 使われ方 |
c.req.valid()でランタイムにもparseされる |
型推論とOpenAPIドキュメント生成のみ |
| ランタイム検証 | あり | なし |
2. TypeScriptの余剰プロパティチェックは「変数の代入」には効かない
c.json({ stats: newStats }) // newStatsは変数なのでチェックされない
余剰プロパティチェック(excess property check)はフレッシュなオブジェクトリテラルにしか適用されません。newStatsは変数への参照なので、構造的部分型(スーパーセットは代入可)が適用され、TSはエラーを出してくれませんでした。
対策:フレッシュなリテラル + satisfies z.infer<...>
return c.json({
article: {
key: article.key,
title: article.title,
publishedAt: article.publishedAt.toISOString(),
},
stats: {
readCount: newStats.readCount,
likeCount: newStats.likeCount,
commentCount: newStats.commentCount,
fetchedAt: newStats.fetchedAt.toISOString(),
}
} satisfies z.infer<typeof CreateStatsResponseSchema>)
- 各フィールドを明示的にリテラルで書き出すことで、DBの戻り値(スーパーセット)を直接渡す経路を断つ
-
satisfies z.infer<typeof Schema>を付けることで、- 余分なフィールドを書いた場合 → 余剰プロパティチェックが効きコンパイルエラー
- 必須フィールドを書き忘れた場合 → 通常の構造的部分型チェックでコンパイルエラー
- 各フィールドの型も厳密にチェックされる(
publishedAt/fetchedAtはDate型のままだと、z.ZodISODateTimeがstringの派生なのでtoISOString()が必要、というのもここで検出できます)
ちなみにテストでこれを防げるかも検証してみましたが、toMatchObjectやobjectContainingのような部分一致アサーションでは余分なキーを検出できません。
CreateStatsResponseSchema.parse(res)が例外を投げないことを確認するテストも、Zodのz.object()はデフォルトで未知のキーを黙ってstripする(strip mode)ため素通りしてしまいます。
「レスポンスの形が仕様と一致しているか」は本質的に型システムの問題で、テストで担保しようとするとtoEqualでの完全一致など意識的に厳密な書き方をする必要があり、通常のテスト習慣では見逃しやすいというのが実感でした。
もう一歩強くする:ヘルパー関数 + 実行時parse
satisfiesは各return文でのオプトインなので、書き忘れる余地が残ります。今回のプロジェクトは私の個人開発なので大きな問題はないですが、チーム開発だとこのルールは徹底されないかもしれません。
また、コンパイル時チェックのみに依存する脆さも解消しきれていません。
そこでレスポンス生成経路を1つの関数に集約し、実行時にもschema.parse()する形にしました。
function typedJson<T extends z.ZodTypeAny>(
c: Context,
schema: T,
data: z.infer<T>, // 引数位置のフレッシュなリテラルにも余剰プロパティチェックは効く
) {
return c.json(schema.parse(data)) // ランタイムでも保証。余分なキーは自動でstripされる
}
// 呼び出し例
return typedJson(c, CreateStatsResponseSchema, {
article: {
key: article.key,
title: article.title,
publishedAt: article.publishedAt.toISOString()
},
stats: {
readCount: newStats.readCount,
/* ... */
},
})
これなら、
- コンパイル時チェックは
satisfiesと同等 - ランタイムでも
.parse()するため、any混入やロジックの穴があっても本番で余分なフィールドが実際に漏れることはない - 必須フィールド不足や型変換ミスは実行時にも例外で検知できる
の両方を同時に得られます。
.parse()を直接呼ぶだけとの違い
typedJson()を作らずに、ただ .parse() をreturn文で直接呼び出すのとは何が違うのでしょうか。
// これだと何が違う?
return c.json(UserSchema.parse({
article: {
key: article.key,
title: article.title,
publishedAt: article.publishedAt.toISOString()
},
stats: {
readCount: newStats.readCount,
/* ... */
},
}), 200)
Zodの.parse()の引数の型はunknown(parse(data: unknown): Output)でした。引数型がunknownである以上、どんなオブジェクトリテラルを渡してもコンパイル時は素通りします。
- 余分なキーがあってもコンパイルエラーにならない
- 実行時にzodが黙ってstripしてくれるので漏洩自体は防げますが、書き間違いには気づけない
- 必須フィールド欠落・型不一致もコンパイルエラーにならず、実行時に
ZodErrorがthrowされるだけ
typedJsonのようにdataをz.infer<typeof Schema>で型注釈された引数として受け取る形にすることで初めて、コンパイル時チェックとランタイムの安全網の両方が手に入ります。
余談:Fastifyはどうしているか
Fastifyは業務でも個人でも使ったことがなく、興味本位でAIに訊いてみた範囲での紹介です。実際に手を動かして検証したわけではない点はご留意ください。
Fastifyはschema.responseを型推論とランタイムのシリアライズの両方に使う設計になっているようでした。
import { ZodTypeProvider, serializerCompiler, validatorCompiler } from '@fastify/type-provider-zod'
server.setValidatorCompiler(validatorCompiler)
server.setSerializerCompiler(serializerCompiler)
server.withTypeProvider<ZodTypeProvider>().get('/stats', {
schema: {
response: {
200: z.object({ readCount: z.number(), likeCount: z.number() }),
},
},
}, async (req, reply) => {
return newStats
})
handlerの戻り値はfast-json-stringifyという専用シリアライザでJSONに変換され、これはスキーマに列挙されたプロパティしか出力しません。
つまりDBの全カラムを持つオブジェクトをそのまま返しても、レスポンスに出る前に自動的に余分なフィールドが物理的に削られる仕組みでした。Fastify公式リポジトリのテストコードでも、この挙動が確認できます。
// additionalProperties:false のスキーマに対し
reply.send({ public: 'ok', secret: 'SHOULD_BE_STRIPPED' })
// → レスポンスは { public: 'ok' } のみ(secretは自動で消える)
Honoでこれをやろうとすると自分でtypedJsonのようなヘルパーを噛ませる必要がありましたが、Fastifyではフレームワーク自体がその役割を担っている、というのが大きな違いのようです。
まとめ
-
@hono/zod-openapiのresponsesスキーマは、OpenAPIドキュメント生成とTypeScript型推論専用で、ランタイムでは検証されない(見落としではなく設計としてそうなっている) - DrizzleのSELECT結果(
InferSelectModel)のようなDBの生の戻り値をAPIレスポンスに直接流用しない。レスポンス生成時は必ずフィールドを明示的に選択する - さらに強くするなら、レスポンス生成をヘルパー関数に集約し、実行時にも
schema.parse()を通す。.parse()を直接呼ぶだけだと引数の型がunknownなのでコンパイル時保証が失われる点に注意