【Astro】Zodを使った「型安全なプロンプト」構築パターンの実践

自作の診断系アプリで、プロンプト生成時のundefined混入により、無意味なAI応答が発生していました。AIアプリ開発で見落とされがちな、プロンプト生成関数の型安全性 について解説します。


問題:型安全でないプロンプト生成の危険性

脆弱な実装 vs 安全な実装の比較

項目脆弱な実装(文字列結合)安全な実装(Zodバリデーション)
データ検証なし(any型を信頼)Zodスキーマによる実行時検証
エラー検出タイミングAI応答後(課金後)API呼び出し前(課金前)
型安全性なし(IDEの補完なし)完全(TypeScript型推論)
undefined混入時の挙動AIが「undefinedについて教えて」と回答ZodErrorで即座に例外
テスタビリティ低(副作用が多い)高(純粋関数化)

脆弱なコード例

typescript
// ❌ 脆弱な実装例
function buildPrompt(data: any) {
  return `以下のWebサイトを診断してください:${data.title}
  内容:${data.content}`;
}

このコードの致命的な問題は以下の通りです:

  1. any型の使用: dataがどんな構造か保証がない
  2. undefined混入リスク: data.titleが存在しない場合、プロンプトに「undefined」という文字列が埋め込まれる
  3. 課金後のエラー: AIにリクエストを送った後(課金が発生した後)に問題が発覚

実際の失敗シナリオ

  1. フロントエンドから不完全なデータが送信される(例:titleフィールドの欠落)
  2. サーバーは検証せずにプロンプトを生成:「以下のWebサイトを診断してください:undefined」
  3. OpenAI/Claude APIに課金リクエストが送信される
  4. AIは「undefinedという概念について説明します...」と無関係な回答を生成
  5. ユーザーは正しい結果を得られず、開発者は課金だけが発生する

詳細はTypeScript公式ドキュメント:NarrowingおよびZod公式ドキュメントを参照してください。


解決策:Zodによる型安全なプロンプト生成

「プロンプトに埋め込むデータ」は、実行時バリデーション によって品質を保証すべきです。

実装パターン:PromptBuilder

Astro (TypeScript) 環境において、以下のような設計を採用しました。

ステップ1:入力スキーマの定義

まず、プロンプトに必要なデータ構造をZodで定義します。

typescript
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で検証済みのオブジェクトだけを受け取る関数を作ります。

typescript
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呼び出し前の検証

typescript
// 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. と打つだけで補完が効きます。プロンプト内の変数埋め込みミスが激減します。

typescript
// ✅ IDEが自動補完してくれる
const headline = lp.hero.headline; // ← 型推論が効く

// ❌ anyだと補完なし
const headline = data.hero.headline; // ← タイポしても気づかない

3. テスト容易性

プロンプト生成ロジックが純粋関数(Pure Function)になるため、単体テストが容易になります。

typescript
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は具体的なエラーメッセージを生成するため、デバッグが容易です。

typescript
// エラー例
{
  "errors": [
    {
      "path": ["hero", "headline"],
      "message": "Required"
    }
  ]
}

実装時のベストプラクティス

スキーマの再利用性

複数のプロンプト生成関数で共通のスキーマを使う場合は、分割して管理します。

typescript
// 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,
  // ...
});

デフォルト値の活用

オプショナルフィールドにはデフォルト値を設定すると、プロンプト生成が安定します。

typescript
const ConfigSchema = z.object({
  language: z.string().default('ja'),
  maxTokens: z.number().default(1000)
});

エラーログの監視

本番環境では、Zodバリデーションエラーをモニタリングツール(Sentry、DataDog等)に送信し、データ品質の問題を早期発見します。


まとめ

  • 「プロンプトに埋め込むデータの品質保証」 は、AIアプリ開発において極めて重要
  • Zodによる実行時バリデーションで、課金前にエラーを検出できる
  • TypeScript型推論により、開発体験が大幅に向上する
  • プロンプト生成が純粋関数化され、テストが容易になる
  • エラーハンドリング、ログ監視を組み合わせることで、本番環境での安定性が向上

「プロンプトエンジニアリング」というとAIへの指示文言ばかり注目されがちですが、データバリデーション も同じくらい重要です。

Zod × TypeScript の組み合わせは、AIアプリ開発においても強力な武器になります。