TypeScript GenerativeAI StructuredOutput Zod

プロンプトのtypoをCIで弾く ── TypeScriptの型でAIへの指示を守る

TypeScript GenerativeAI StructuredOutput Zod

はじめに

こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています kinosuke01 といいます。先日ゴジラ-0.0のティザー映像が解禁されましたね。公開が楽しみです。

さて、GMOペパボでは、ユーザーとの対話をもとにWebサイトをまるごと自動生成するAIサイトエージェントを提供しています。ユーザーが「カフェのサイトを作りたい」「コーポレートサイトがほしい」と伝えるだけで、ページ構成からデザインテーマ、コンテンツまでを一括で生成し、すぐに公開できるWebサイトを作り上げます。

img01 img02

この仕組みの裏側では、Webサイトの構造をすべてJSONで表現しています。生成AIに対して「このJSONを埋めてください」と指示し、Structured Outputでフォーマットを固定することで、安定した出力を得ています。

しかし、JSONの構造を固定するだけでは不十分でした。各プロパティに何を入れるべきかを正しく伝えるために、システムプロンプトでフィールドごとの説明を与えています。ここで問題になったのが、プロンプトに書いたプロパティ名と実際のコードの型定義がズレるリスクです。

この記事では、TypeScriptの型システムを活用してプロンプトの正しさをコンパイル時に保証する仕組みを紹介します。

サイト生成の仕組み

JSONでWebサイトを表現する

私たちのシステムでは、Webサイトを「セクション」の組み合わせで表現しています。ヒーローセクション、特徴紹介セクション、料金表セクションなど、複数のセクションを用意しており、それぞれがJSON構造を持ちます。

たとえば、ヒーローセクションはこのような構造です。

{
  "component": "HeroSection",
  "props": {
    "headline": { "text": "想いを、かたちに。" },
    "title": { "text": "あなたの「やりたい」を実現するために" },
    "primaryButton": { "label": "お問い合わせ", "href": "/contact" },
    "layout": "split-content-image",
    "image": { "imageId": "hero-1", "alt": "メインビジュアル" }
  }
}

3つのエージェントによる段階的な生成

サイト生成は、1回のAPI呼び出しで完結するわけではありません。大きく3つの生成ステップに分けて処理を進めます。

ユーザーのヒアリング情報
          │
          ▼
┌──────────────────────┐
│ PageAndSectionPlanner│  ← ページ構成とセクション選定
└─────────┬────────────┘
          ▼
┌─────────────────────┐
│   ThemeDeterminer   │  ← カラー・フォントの決定
└─────────┬───────────┘
          ▼
┌──────────────────────┐
│ SectionValueGenerator│  ← 各セクションのコンテンツ生成
└─────────┬────────────┘
          ▼
      Webサイト完成

Structured Outputでフォーマットを固定する

各ステップの出力は、Zodスキーマで定義した構造に従います。ZodスキーマをJSON Schemaに変換し、Gemini APIのresponseSchemaに渡すことで、AIの出力フォーマットを固定しています。

// Zodスキーマを定義
export const themeConfigSchema = themeValuesSchema.extend({
  themeId: z.string().max(MAX_ID),
  fontId: z.enum(FONT_IDS).optional(),
});

// JSON Schemaに変換してStructured Outputに渡す
const themeConfigResponseSchema = zodToResponseSchema(themeConfigSchema);

// AIにリクエスト
const result = await this.model.generateContent(prompt, {
  responseSchema: themeConfigResponseSchema,
});

これにより、AIが自由なフォーマットでJSONを返してしまう問題は解消されます。

システムプロンプトで「意味」を伝える

構造を固定しただけでは、AIは各フィールドに何を入れるべきか判断できません。そこで、システムプロンプトでフィールドごとの説明を与えています。

たとえばセクションのコンテンツを生成する際、こんなプロンプトを組み立てます。

## セクション1: HeroSection
### フィールド説明
  - layout: レイアウトパターン。split-content-image: コンテンツ左・画像右(全幅)、...
  - headline.text: ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)
  - title.text: キャッチコピーを補足するサブメッセージ(15〜30文字)
  - primaryButton.label: メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」
  - primaryButton.href: ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)

headline.textprimaryButton.label といったドットパスでプロパティの位置を指定し、AIに「ここには何文字くらいの、どんなテキストを入れてほしい」と具体的に伝えています。

課題:プロンプトとコードの乖離リスク

ここまでの仕組みはうまく動いていました。しかし、開発を進める中で不安が生まれます。

プロパティ名が変わったらどうなる?

フィールド説明のドットパス(headline.textprimaryButton.label)は、セクションのProps型と対応しています。もしリファクタリングで headlinemainHeading に変えたとき、フィールド説明のパスも更新しなければなりません。

しかし、フィールド説明がただの文字列であれば、Props型の変更に追従できているかはコードを目視で確認するしかありません。

typoは静かに品質を劣化させる

headline.textheadling.text とtypoしたらどうなるでしょうか。プログラムはエラーを出しません。プロンプトの中に誤ったパスが紛れ込むだけです。AIはそのパスに対応するフィールドを見つけられず、適切なコンテンツを生成できなくなります。

結果として、ある日突然「ヒーローセクションのキャッチコピーがなぜか空っぽのサイトが生成された」といった不具合が起きる可能性があります。Structured Outputで構造は正しいのに、中身の品質が落ちる。原因の特定が難しい厄介な問題です。

解決策:TypeScriptの型でプロンプトを縛る

アプローチの全体像

まず、私たちが取ったアプローチの全体像を示します。

セクションごとに「フィールド説明オブジェクト」を定義し、それをプロンプト組み立て時に埋め込む構成にしました。

const fieldDescs = {
  layout: "レイアウトパターン。split-content-image: コンテンツ左・画像右...",
  "headline.text":
    "ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)",
  "title.text": "キャッチコピーを補足するサブメッセージ(15〜30文字)",
  "primaryButton.label":
    "メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」",
  "primaryButton.href":
    "ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)",
};

ドットパスをキー、AIへの説明を値とするシンプルなオブジェクトです。プロンプト組み立て時にこのオブジェクトを展開し、箇条書き形式でプロンプトに埋め込みます。

function formatSectionFieldDescs(descs: Record<string, string>): string {
  return Object.entries(descs)
    .map(([path, desc]) => `  - ${path}: ${desc}`)
    .join("\n");
}

ここまでは特別なことはしていません。ポイントはこの次です。

このオブジェクトのキーにTypeScriptの型制約を加えることで、typoやプロパティ名の変更に対してコンパイルエラーで気づけるようにしました。

satisfiesで定義を検証する

具体的に見ていきましょう。各セクションのメタデータ定義で satisfies を使って型制約をかけます。

export const heroSectionMeta = {
  name: "HeroSection",
  // ...
  fieldDescs: {
    layout: buildLayoutFieldDesc(layoutDescs),
    "headline.text":
      "ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)",
    "title.text": "キャッチコピーを補足するサブメッセージ(15〜30文字)",
    "primaryButton.label":
      "メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」",
    "primaryButton.href":
      "ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)",
    "secondaryButton.label":
      "サブのCTAボタンのラベル(2〜8文字)。例: 「詳しく見る」「サービス一覧」",
  } satisfies ValidFieldDescs<HeroSectionProps>,  // ← ここ
} as const;

末尾の satisfies ValidFieldDescs<HeroSectionProps> が効いています。もし "headline.text""headling.text" とtypoしたら、コンパイルエラーになります。

// typoするとコンパイルエラー
"headling.text": "ページ全体のキャッチコピー..."
// ~~~~~~~~~~~~~~
// Type '"headling.text"' is not assignable to type 'FieldDescPaths<HeroSectionProps>'

Props型のプロパティ名を変更した場合も同様です。headlinemainHeading にリネームすれば、"headline.text" は無効なパスになり、コンパイラが教えてくれます。

ValidFieldDescsの仕組み

では ValidFieldDescs がどのように機能しているかを見てみましょう。

export type ValidFieldDescs<Props> = Partial<
  Record<FieldDescPaths<Props>, string>
>;

FieldDescPaths<Props> がProps型から「有効なドットパス」のunion型を自動生成する部分です。

export type FieldDescPaths<T, D extends number = 4> =
  D extends 0
    ? never
    : T extends object
      ? IsIndexSignature<T> extends true
        ? never
        : {
            [K in keyof NonNullish<T> & string]:
              | K
              | (NonNullish<T>[K] extends Array<infer E>
                  ? E extends object
                    ? `${K}[]` | `${K}[].${FieldDescPaths<E, Prev[D]>}`
                    : `${K}[]`
                  : NonNullish<T>[K] extends object
                    ? `${K}.${FieldDescPaths<NonNullish<T>[K], Prev[D]>}`
                    : never);
          }[keyof NonNullish<T> & string]
      : never;

一見複雑ですが、やっていることはシンプルです。Props型を再帰的に走査し、有効なドットパスをすべてunion型として列挙します。

  • トップレベル: "layout"
  • ネストしたオブジェクト: "headline.text", "primaryButton.label"
  • 配列の要素: "orderList.items[].title.text"

たとえば HeroSectionProps に適用すると、"layout" | "headline" | "headline.text" | "title" | "title.text" | "primaryButton" | "primaryButton.label" | "primaryButton.href" | ... のようなunion型が得られます。このunion型に含まれないキーを fieldDescs に書くと、satisfies によりコンパイルエラーになるというわけです。

テーマプロンプトでも同じアプローチ

テーマ決定のプロンプトでも、カラーパスの定数を型で縛っています。

type ColorsInferred = z.infer<typeof colorsSchema>;

/** "group.field" 形式のパス型。colorsSchemaと一致しない場合コンパイルエラー */
type ColorPath<G extends keyof ColorsInferred> =
  `${G}.${string & keyof ColorsInferred[G]}`;

/** スキーマと連動した型安全なパス定数 */
const BG_PRIMARY_PATH: ColorPath<"background"> = "background.primary";
const TYPOGRAPHY_CONTRAST_PATH: ColorPath<"typography"> = "typography.contrastText";

これらの定数はシステムプロンプトに埋め込まれます。

const themePrompt = `
...
- {TYPOGRAPHY_CONTRAST_KEY} と {BG_PRIMARY_KEY} は必ず高コントラストを保ってください
...
`;

// プロンプト組み立て時に定数を埋め込む
return themePrompt
  .replace(/{TYPOGRAPHY_CONTRAST_KEY}/g, TYPOGRAPHY_CONTRAST_PATH)
  .replace(/{BG_PRIMARY_KEY}/g, BG_PRIMARY_PATH);

もし colorsSchema から contrastText が削除されたり名前が変わったりすれば、TYPOGRAPHY_CONTRAST_PATH の定義でコンパイルエラーが発生します。プロンプトに存在しないプロパティ名が紛れ込む余地がありません。

フィールド説明の型安全性も

テーマのカラーフィールド説明にも、同じ考え方を適用しています。

/** palette 各フィールドの説明(AI プロンプト用) */
export const paletteFieldDescs: Record<
  keyof z.infer<typeof paletteSchema>,
  string
> = {
  primary: "パレットの主役となる色(全体の起点)",
  light: "背景とprimaryを繋ぐ低彩度カラー",
  deep: "primary のダークトーン(引き締め)",
};

Record<keyof z.infer<typeof paletteSchema>, string> により、paletteSchema のすべてのフィールドに対して説明を書くことが強制されます。フィールドを追加したのに説明を書き忘れたらコンパイルエラー、存在しないフィールドの説明を書いてもコンパイルエラーです。

まとめ

生成AIを活用したシステムでは、プロンプトの品質がそのままサービスの品質に直結します。しかしプロンプトは往々にしてただの文字列として扱われ、コードとの整合性は開発者の注意力に依存しがちです。

私たちはTypeScriptの型システムを使って、プロンプトに埋め込むフィールド名やパスをコードの型定義と連動させることで、この問題を解決しました。特別なツールやライブラリは必要ありません。satisfies、Template Literal Types、Record<keyof T, string> といったTypeScriptの標準的な機能だけで実現できます。

この仕組みの良いところは、CIの型チェック(tsc --noEmit)で自然にカバーされる点です。プロンプト専用のテストを書く必要はなく、普段の開発フローの中で、プロパティ名の変更やtypoが自動的に検出されます。

プロンプトもコードの一部です。型で守れるものは、型で守りましょう。