LLM出力をTypeScriptで型安全に:Zodスキーマバリデーション実装パターン
Claude APIを使ってJSONを返させる実装は、ローカルでは問題なく動いていても、本番環境でフィールドの欠落やデータ型の不一致が原因のランタイムエラーに悩まされることがあります。LLM出力を「たぶん合っているだろう」で扱うのは、静的型付けのない時代のJavaScriptと同じリスクを抱えています。本記事では、TypeScriptとZodを組み合わせてLLM出力を型安全に扱う実践的なパターンを解説します。
LLM出力がスキーマに従わない理由
LLMは与えられたプロンプトに対して確率的にトークンを生成します。「必ずJSON形式で返してください」と明示しても、モデルがスキーマに100%準拠することは保証されません。
よくある失敗パターンを挙げてみましょう。
- フィールド名の揺れ:
userIdのつもりがuser_idやidになる - 型の不一致:数値を期待しているフィールドに文字列
"42"が来る - 必須フィールドの欠落:モデルが不要と判断してフィールドを省略する
- 余分なフィールドの混入:プロンプトで触れなかったキーが追加される
- JSONの構文エラー:長い出力でトークン上限に達し、途中で切れる
AnthropicのTool UseやJSON modeを活用するとリスクは下がりますが、ゼロにはなりません。フィールドの型や値の範囲まで保証する仕組みは、現時点ではAPIレベルに存在しないためです。
TypeScriptの型チェックがLLM出力に効かない理由
TypeScriptの型システムはコンパイル時にしか機能しません。APIレスポンスやLLM出力のような外部データは、ランタイムでは unknown または any として扱われます。
// 型アサーションは「コンパイラへの約束」であってランタイムの保証ではない
const response = await anthropic.messages.create({ ... });
const parsed = JSON.parse(response.content[0].text) as MySchema; // ← 危険
console.log(parsed.userId.toUpperCase()); // userIdがundefinedなら即クラッシュ
as MySchema というキャストはコンパイラを黙らせるだけで、実際のデータ構造をチェックしません。JSON.parse が成功しても、中身が期待通りかどうかはまったく別問題です。
LLM出力を型安全に扱うには「TypeScriptの型情報」と「ランタイムバリデーション」の両方が必要です。
ZodによるLLM出力のバリデーション
スキーマ定義とバリデーションの基本
Zod はスキーマ定義からTypeScriptの型を自動生成し、ランタイムバリデーションも同時に行えるライブラリです。エラーメッセージが詳細でデバッグしやすい点も、LLM出力との相性が良い理由のひとつです。
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).optional().default([]),
});
// スキーマからTypeScript型を導出(二重管理不要)
type Product = z.infer<typeof ProductSchema>;
LLMレスポンスを受け取るラッパー関数
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function extractProduct(userInput: string): Promise<Product> {
const message = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: `以下のテキストから商品情報をJSON形式で抽出してください。
フィールド: id(UUID), name, price(数値), category(electronics/clothing/food), tags(配列)
テキスト: ${userInput}
JSONのみを返してください。`,
},
],
});
const rawText = message.content[0].type === "text" ? message.content[0].text : "";
// モデルが ```json ... ``` で囲むケースにも対応
const jsonMatch = rawText.match(/```json\s*([\s\S]*?)\s*```/)
?? rawText.match(/({[\s\S]*})/);
if (!jsonMatch) {
throw new Error("レスポンスにJSONが含まれていません");
}
const parsed = JSON.parse(jsonMatch[1]);
// バリデーション失敗時はZodErrorをスロー
return ProductSchema.parse(parsed);
}
例外を使いたくない場合は safeParse を使うと、結果をオブジェクトで受け取れます。
const result = ProductSchema.safeParse(parsed);
if (!result.success) {
console.error("バリデーション失敗:", result.error.flatten());
// result.error.issues で各フィールドのエラー詳細を確認できる
} else {
const product = result.data; // ここではProduct型が確定している
}
バリデーション失敗時のリトライ戦略
LLM出力のバリデーション失敗は一過性のことも多く、リトライが有効です。重要なのは「何が失敗したか」をモデルにフィードバックすることです。
import { ZodError } from "zod";
async function extractProductWithRetry(
userInput: string,
maxRetries = 2
): Promise<Product> {
let lastError: ZodError | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const errorContext =
lastError && attempt > 0
? `\n前回の出力はバリデーションエラーでした: ${JSON.stringify(lastError.flatten())}\n修正してください。`
: "";
const message = await client.messages.create({
model: "claude-opus-4-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: `商品情報をJSON形式で抽出してください。${errorContext}
テキスト: ${userInput}`,
},
],
});
const rawText =
message.content[0].type === "text" ? message.content[0].text : "";
try {
const jsonMatch = rawText.match(/({[\s\S]*})/);
if (!jsonMatch) throw new Error("JSONが見つかりません");
const parsed = JSON.parse(jsonMatch[1]);
return ProductSchema.parse(parsed);
} catch (e) {
if (e instanceof ZodError) {
lastError = e;
continue; // ZodエラーならZodErrorの詳細を次のプロンプトへ
}
throw e; // JSONパースエラーなど他のエラーは即スロー
}
}
throw new Error(`${maxRetries + 1}回試行しましたがバリデーションを通過できませんでした`);
}
フォールバックとして「部分的に有効なデータだけ使う」という選択肢もあります。各フィールドを .optional() にしたゆるめのスキーマで受け取り、必須フィールドだけ別途チェックするアプローチで、ユーザー体験を損なわない範囲で段階的に対応できます。
スキーマ設計の実践的なポイント
バリデーション失敗のログを記録する
本番で突然エラーが起きてから原因を調べるのでは遅すぎます。開発段階からバリデーション失敗を構造化して記録しておくと、モデルの出力傾向や特定プロンプトとの相関が見えてきます。
type ValidationLog = {
timestamp: string;
prompt: string;
rawOutput: string;
errors: z.ZodIssue[];
attempt: number;
};
function logValidationFailure(
prompt: string,
rawOutput: string,
error: ZodError,
attempt: number
) {
const log: ValidationLog = {
timestamp: new Date().toISOString(),
prompt,
rawOutput,
errors: error.issues,
attempt,
};
if (process.env.NODE_ENV === "development") {
console.warn("[ValidationFailure]", JSON.stringify(log, null, 2));
}
}
ログを集計すると「price フィールドで文字列が返るケースが30%」のような傾向が分かります。その場合は z.coerce.number() で文字列から数値への変換を許容するのが現実的な対応です。スキーマを厳格にするか変換を許容するかのトレードオフは、ログデータを見ながら判断しましょう。
strict と strip の使い分け
Zodはデフォルト(strip モード)で未知のフィールドを除去します。strict() にすると未知のフィールドで例外になるため、LLMが余分なフィールドを返しやすいケースでは strip の方が現実的です。
const ProductSchema = z.object({ ... }); // strip(デフォルト): 余分なフィールドを無視
const StrictProductSchema = z.object({ ... }).strict(); // 未知フィールドでエラー
スキーマはモジュールレベルで定義する
スキーマをリクエストのたびに z.object({...}) で再生成するコードをよく見かけますが、モジュールレベルで一度定義しておくのが基本です。ホットパスでは特に意識しておきましょう。
また、バリデーションと後続の変換処理は分離しておくと可読性が上がります。
// バリデーションのみのスキーマ
const RawProductSchema = z.object({ ... });
// 変換はドメイン層で行う
function toProductEntity(raw: z.infer<typeof RawProductSchema>): ProductEntity {
return { ...raw, displayPrice: `¥${raw.price.toLocaleString()}` };
}
型チェックとランタイムバリデーションの両輪を整えることで、LLM出力を組み込んだシステムの信頼性は大きく向上します。まずは最もクリティカルな出力から safeParse を導入し、ログを取りながらスキーマを育てていくアプローチが、現場で無理なく続けられる方法です。