Fastify ベストプラクティス #4: Ajvの代わりにZodでスキーマを管理する
サーバーサイドアプリケーションを構築する際、外部から受け取るHTTPリクエストのパラメータを検証(バリデーション)することは、サービスの安定性とセキュリティを保証するための第一防衛線です。パフォーマンスと低オーバーヘッドで知られるフレームワークであるFastifyには、スキーマ検証を効率的に処理するための強力なAjvモジュールが標準で組み込まれており、すぐに利用できます。
しかし、多くのTypeScriptプロジェクトでの実践を通じて、私はネイティブのAjvを用いる方法が必ずしも完璧ではないことに気づきました。最高の開発体験と型安全性を追求する私たちにとって、無視できない2つのペインポイントが存在したのです。
Ajvのペインポイント:煩雑さと分断
まず、ネイティブのAjvを使用した典型的な検証の例を見てみましょう。
import { FastifyInstance } from 'fastify';
// JSON Schema仕様で定義
const paramsSchema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
},
required: ['id'],
additionalProperties: false,
};
// req.paramsの型を手動で定義する必要がある
interface IParams {
id: string;
}
const routes = async (fastify: FastifyInstance) => {
fastify.get<{ Params: IParams }>( // 型を手動で紐付ける
'/:id',
{
schema: {
params: paramsSchema,
},
},
async (req, reply) => {
const { id } = req.params;
// ...
return reply.status(200).send();
},
);
};
export default routes;
この方法でも目的を達成することはできますが、欠点は明らかです。
- 定義が煩雑で冗長:
AjvのJSON Schema記法は比較的記述量が多くなります。検証ルールが複雑でフィールド数が多くなると、スキーマオブジェクトは非常に肥大化し、可読性もメンテナンス性も低下します。 - 型システムとの分断:
Ajvのスキーマ定義とTypeScriptの型システムは完全に独立しています。つまり、実行時の検証のためにスキーマを一度定義したにもかかわらず、コンパイル時の型ヒントを得るためには、対応するTypeScriptのinterfaceやtypeをもう一度手動で宣言する必要があります。これは間違いなく作業の重複を増やし、両者の間に不整合が生まれるリスクをもたらします。json-schema-to-tsのようなツールを使えばスキーマから型を生成できますが、それはまた新たな依存関係とビルドステップを追加することになります。
では、もっとモダンで、TypeScriptにとってよりフレンドリーな方法はないのでしょうか? 答えはもちろん、「Yes」です。それがZodです。
解決策:Zodを導入し、型と検証を統一する
ZodはTypeScriptファーストなバリデーションライブラリで、スキーマを一度定義するだけで、強力な実行時検証と静的な型推論の両方を手に入れることができます。「接着剤」のような役割を果たすfastify-type-provider-zodという依存パッケージを通じて、FastifyとZodを完璧に連携させることができます。
まず、依存関係をインストールします。
npm i fastify-type-provider-zod zod
そして、コードを次のようにリファクタリングします。
import Fastify from 'fastify';
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod';
const app = Fastify({ logger: true });
// 1. FastifyがバリデーションとシリアライズにZodを使用するよう設定
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// 2. ルート登録時に withTypeProvider<ZodTypeProvider>() でZodの型プロバイダーを有効化
app.withTypeProvider<ZodTypeProvider>().route({
method: 'GET',
url: '/',
// 3. ZodのチェーンAPIでスキーマを直接定義。明確で直感的
schema: {
querystring: z.object({
name: z.string().min(4).default('Guest'),
}),
response: {
200: z.object({
greeting: z.string(),
}),
},
},
handler: (req, res) => {
// 4. ✨魔法のような体験を!✨
// req.query は自動的に { name: string } 型として推論される
// interfaceやtypeを手動で定義する必要は一切なし
const { name } = req.query;
// nameのstringメソッドを安心して使え、IDEの自動補完も機能する
const greeting = `Hello, ${name.toUpperCase()}!`;
// レスポンスボディもresponseスキーマに基づいて検証・シリアライズされる
res.status(200).send({ greeting });
},
});
app.listen({ port: 3000 });
主要な変更点の解説
このコードに隠された魔法を解き明かしてみましょう。
app.setValidatorCompiler(validatorCompiler);とapp.setSerializerCompiler(serializerCompiler);:この2行が中心的な設定です。これらはFastifyに対して、「もう標準のAjvは使わず、Zodを使ってリクエストを検証(validate)し、レスポンスをシリアライズ(serialize)してください」と指示する役割を果たします。app.withTypeProvider<ZodTypeProvider>():この魔法のようなメソッドが、あなたのルートにZodの型能力を注入します。これがあるからこそ、Fastifyはschemaフィールドに書かれたZodオブジェクトを理解し、完璧な型推論を提供してくれるのです。この瞬間から、handler内のreqオブジェクト(query,body,paramsを含む)はすべて、正確な型を持つことになります。
responseスキーマを定義するさらなるメリット
上記の例ではresponseのスキーマも定義していることにお気づきかもしれません。これは単にドキュメントを明確にしたり、出力フォーマットの正しさを保証するためだけではありません。それには重要なパフォーマンス上のメリットがあります。Fastifyはこのスキーマを利用して、最適化されたJSONシリアライズ関数を生成します。その速度は標準のJSON.stringifyよりもかなり高速です。高負荷なシナリオにおいては、これは無視できないパフォーマンス向上となります。
まとめ
AjvからZodへ移行することで得られるものは、単に簡潔な構文だけではありません。私たちが手に入れるのは、以下のものです。
- Single Source of Truth(信頼できる唯一の情報源):一つのスキーマ定義が、実行時の検証とコンパイル時の型チェックの両方を担います。重複した定義や型との不整合といった悩みから解放されます。
- 卓越した開発体験(DX):ZodのチェーンAPIの利便性と、強力な型推論がもたらす自動補全や静的チェックを享受することで、コーディング段階でバグの侵入を防ぎます。
- より高いコードの堅牢性:リクエストパラメータとレスポンスボディに対して厳格な型検証を行うことで、アプリケーションの安定性と信頼性を大幅に向上させます。
TypeScriptでFastifyアプリケーションを構築するすべての開発者にとって、Zodを導入することは間違いなくリターンの大きい投資と言えるでしょう。
より完全なスキーマの例については、私が公開しているオープンソースプロジェクトを参考にしてください。この記事で言及したベストプラクティスが完全に適用されています。