【Astro】Zodを使った「型安全なプロンプト」構築パターンの実践
自作の診断系アプリで、プロンプト生成時のundefined混入により、無意味なAI応答が発生していました。AIアプリ開発で見落とされがちな、プロンプト生成関数の型安全性 について解説します。
問題:型安全でないプロンプト生成の危険性
脆弱な実装 vs 安全な実装の比較
| 項目 | 脆弱な実装(文字列結合) | 安全な実装(Zodバリデーション) |
|---|---|---|
| データ検証 | なし(any型を信頼) | Zodスキーマによる実行時検証 |
| エラー検出タイミング | AI応答後(課金後) | API呼び出し前(課金前) |
| 型安全性 | なし(IDEの補完なし) | 完全(TypeScript型推論) |
undefined混入時の挙動 | AIが「undefinedについて教えて」と回答 | ZodErrorで即座に例外 |
| テスタビリティ | 低(副作用が多い) | 高(純粋関数化) |
脆弱なコード例
// ❌ 脆弱な実装例
function buildPrompt(data: any) {
return `以下のWebサイトを診断してください:${data.title}
内容:${data.content}`;
}
このコードの致命的な問題は以下の通りです:
any型の使用:dataがどんな構造か保証がないundefined混入リスク:data.titleが存在しない場合、プロンプトに「undefined」という文字列が埋め込まれる- 課金後のエラー: AIにリクエストを送った後(課金が発生した後)に問題が発覚
実際の失敗シナリオ
- フロントエンドから不完全なデータが送信される(例:
titleフィールドの欠落) - サーバーは検証せずにプロンプトを生成:「以下のWebサイトを診断してください:undefined」
- OpenAI/Claude APIに課金リクエストが送信される
- AIは「undefinedという概念について説明します...」と無関係な回答を生成
- ユーザーは正しい結果を得られず、開発者は課金だけが発生する
詳細はTypeScript公式ドキュメント:NarrowingおよびZod公式ドキュメントを参照してください。
解決策:Zodによる型安全なプロンプト生成
「プロンプトに埋め込むデータ」は、実行時バリデーション によって品質を保証すべきです。
実装パターン:PromptBuilder
Astro (TypeScript) 環境において、以下のような設計を採用しました。
ステップ1:入力スキーマの定義
まず、プロンプトに必要なデータ構造をZodで定義します。
import { z } from 'zod';
const LPSchema = z.object({
title: z.string().min(1, "タイトルは必須です"),
description: z.string().optional(),
hero: z.object({
headline: z.string(),
subHeadline: z.string().optional()
}),
features: z.array(z.object({
title: z.string(),
description: z.string()
})).optional(),
// ...省略
});
// TypeScript型を自動生成
type LPData = z.infer<typeof LPSchema>;
詳細はZod Schema Definitionを参照してください。
ステップ2:型安全な変換関数
Zodで検証済みのオブジェクトだけを受け取る関数を作ります。
export function buildGEOPrompt(data: unknown): string {
try {
// ここでバリデーション。不正なデータなら即座に例外を投げる
const lp = LPSchema.parse(data);
return `
# Target Content
Title: ${lp.title}
Description: ${lp.description || 'N/A'}
## Hero Section
Headline: ${lp.hero.headline}
Sub-headline: ${lp.hero.subHeadline || 'Not provided'}
## Features
${lp.features?.map(f => `- ${f.title}: ${f.description}`).join('\n') || 'No features listed'}
`.trim();
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Prompt validation failed:', error.errors);
// エラーログをモニタリングツールに送信(推奨)
// await logValidationError({ schema: 'LPSchema', errors: error.errors });
throw new Error(`Invalid prompt data: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
}
ステップ3:API呼び出し前の検証
// API Route例(Astro)
export const POST = async ({ request }) => {
const body = await request.json();
try {
// ✅ 課金前にデータを検証
const prompt = buildGEOPrompt(body);
// ここで初めてAI APIを呼び出す
const response = await openai.chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: prompt }]
});
return new Response(JSON.stringify(response));
} catch (error) {
// データ不備の場合は400エラーを返す(課金は発生していない)
return new Response(JSON.stringify({ error: error.message }), {
status: 400
});
}
};
メリット:なぜZodを使うべきか
1. Fail Fast(早期発見)
AIにリクエストを送る前(課金が発生する前)に、データ不備を検知できます。
必須項目である headline が欠けている場合、AIは幻覚を見るのではなく、アプリ側で ZodError としてハンドリングできます。
コスト削減効果: 不正なリクエストによる無駄な課金を防止できます(特にGPT-4など高額モデルで効果大)。
2. 開発体験(DX)の向上
VS Code等のエディタで、lp. と打つだけで補完が効きます。プロンプト内の変数埋め込みミスが激減します。
// ✅ IDEが自動補完してくれる
const headline = lp.hero.headline; // ← 型推論が効く
// ❌ anyだと補完なし
const headline = data.hero.headline; // ← タイポしても気づかない
3. テスト容易性
プロンプト生成ロジックが純粋関数(Pure Function)になるため、単体テストが容易になります。
import { describe, it, expect } from 'vitest';
describe('buildGEOPrompt', () => {
it('正しいデータで正常にプロンプトを生成', () => {
const validData = {
title: 'My Landing Page',
hero: { headline: 'Welcome' }
};
const prompt = buildGEOPrompt(validData);
expect(prompt).toContain('My Landing Page');
});
it('必須フィールド欠落時にエラーを投げる', () => {
const invalidData = { title: '' }; // headlineが欠けている
expect(() => buildGEOPrompt(invalidData)).toThrow();
});
});
4. エラーメッセージの明確化
Zodは具体的なエラーメッセージを生成するため、デバッグが容易です。
// エラー例
{
"errors": [
{
"path": ["hero", "headline"],
"message": "Required"
}
]
}
実装時のベストプラクティス
スキーマの再利用性
複数のプロンプト生成関数で共通のスキーマを使う場合は、分割して管理します。
// schemas/common.ts
export const HeroSchema = z.object({
headline: z.string(),
subHeadline: z.string().optional()
});
// schemas/lp.ts
export const LPSchema = z.object({
title: z.string().min(1),
hero: HeroSchema,
// ...
});
デフォルト値の活用
オプショナルフィールドにはデフォルト値を設定すると、プロンプト生成が安定します。
const ConfigSchema = z.object({
language: z.string().default('ja'),
maxTokens: z.number().default(1000)
});
エラーログの監視
本番環境では、Zodバリデーションエラーをモニタリングツール(Sentry、DataDog等)に送信し、データ品質の問題を早期発見します。
まとめ
- 「プロンプトに埋め込むデータの品質保証」 は、AIアプリ開発において極めて重要
- Zodによる実行時バリデーションで、課金前にエラーを検出できる
- TypeScript型推論により、開発体験が大幅に向上する
- プロンプト生成が純粋関数化され、テストが容易になる
- エラーハンドリング、ログ監視を組み合わせることで、本番環境での安定性が向上
「プロンプトエンジニアリング」というとAIへの指示文言ばかり注目されがちですが、データバリデーション も同じくらい重要です。
Zod × TypeScript の組み合わせは、AIアプリ開発においても強力な武器になります。