Claude API でツール呼び出しを実装し始めた当初、私のコードは as any が随所に散らばっていました。
tool_use ブロックの input を受け取る部分で Record<string, unknown> になり、それを処理するたびにキャストを重ねる。TypeScript を使っているのに、肝心なところで型の恩恵が受けられない状態です。「動いているから問題ない」と思っていたのですが、LLM が予期しない構造の値を返してきたとき、エラーになるのは本番環境の実行時でした。
Zod を導入してからは、この問題がほぼ解消しました。スキーマを1箇所に定義するだけで、Claude API のツール定義・TypeScript の型・ランタイムバリデーションの3つが一致した状態を保てるようになります。
なぜ Zod を選ぶのか
TypeScript の型チェックはコンパイル時にしか働きません。LLM が返す値はランタイムに生成されるため、TypeScript の型定義だけでは安全性の保証に限界があります。
Zod を使うと2つのことが同時にできます。
- スキーマから TypeScript の型を自動生成する(
z.infer<typeof Schema>) - 実行時に
safeParseでバリデーションして、型が正しくない値を弾く
さらに、後述する zod-to-json-schema ライブラリを使えば、Zod スキーマを Claude API が要求する JSON Schema 形式に変換できます。同じスキーマ定義から「型・バリデーション・API定義」の3つを生成できるのが最大の利点です。
インストールと基本セットアップ
npm install zod zod-to-json-schema @anthropic-ai/sdkシンプルなツールを例に、基本的な実装を見ていきます。
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// ① Zodスキーマでツールの入力型を定義
const GetWeatherSchema = z.object({
location: z.string().describe("都市名(例: 東京、大阪、New York)"),
unit: z
.enum(["celsius", "fahrenheit"])
.optional()
.describe("温度の単位。省略時はcelsius"),
});
// ② TypeScriptの型を自動生成(型定義を別途書く必要がない)
type GetWeatherInput = z.infer<typeof GetWeatherSchema>;
// ③ Anthropic APIのツール定義形式に変換
const getWeatherTool: Anthropic.Tool = {
name: "get_weather",
description: "指定した都市の現在の天気を取得します",
input_schema: zodToJsonSchema(GetWeatherSchema, {
$refStrategy: "none", // 重要: 循環参照を展開する
}) as Anthropic.Tool["input_schema"],
};ここで重要なのが $refStrategy: "none" の指定です。デフォルトでは $ref を使った参照形式で JSON Schema が出力されますが、Claude API のパーサーがこれを解釈できないことがあります。"none" を指定するとインライン展開されるため、安定して動作します。私はこれを知らずに1時間ほど悩みました。
ツール呼び出し結果を型安全に処理する
LLM がツールを呼び出したとき、tool_use ブロックの input は Record<string, unknown> 型です。ここで Zod の safeParse を挟みます。
async function processToolCall(
toolUseBlock: Anthropic.ToolUseBlock
): Promise<string> {
if (toolUseBlock.name === "get_weather") {
// safeParse: 失敗時も例外を投げない
const result = GetWeatherSchema.safeParse(toolUseBlock.input);
if (!result.success) {
// バリデーション失敗の詳細をログに残す
console.error(
"Tool input validation failed:",
result.error.format()
);
return JSON.stringify({
error: "入力の形式が正しくありません",
details: result.error.format(),
});
}
// result.data は GetWeatherInput 型として確定している
const { location, unit = "celsius" } = result.data;
const weather = await fetchWeather(location, unit);
return JSON.stringify(weather);
}
return JSON.stringify({ error: "Unknown tool" });
}safeParse と parse の使い分けについて。parse は失敗時に ZodError をスロースします。ツール実行の文脈では予期しない失敗に対して柔軟に対応したいので、safeParse を使う方が制御しやすいです。エラーの詳細を result.error.format() で取れるのも便利で、LLM がどんな値を渡してきたかをログから追いやすくなります。
詰まったポイント①: optional と nullable の違い
Claude API がツールを呼び出すとき、省略可能なフィールドに null を渡してくることがあります。Zod の optional() は undefined を許容しますが、null は弾きます。
// ⚠️ LLMがnullを渡してくるとバリデーション失敗する
const UnsafeSchema = z.object({
description: z.string().optional(),
});
// ✅ nullableも合わせて指定する
const SafeSchema = z.object({
description: z.string().optional().nullable().describe("説明文(省略可能)"),
});本番環境で気づいたのですが、LLM は「省略可能なフィールド」に対して、省略する場合と null を渡す場合の両方を取りうります。省略可能なフィールドには optional().nullable() のセットが安全です。
詰まったポイント②: ネストした union 型の変換
z.union() を使ったスキーマは、変換後の JSON Schema が期待通りにならないことがあります。
// ⚠️ union型はLLMが正確にどちらかを選べないことがある
const ProblematicSchema = z.object({
action: z.union([
z.object({ type: z.literal("search"), query: z.string() }),
z.object({ type: z.literal("fetch"), id: z.number() }),
]),
});
// ✅ フラットなスキーマに分解する方が安定する
const StableSchema = z.object({
type: z.enum(["search", "fetch"]).describe("実行するアクション種別"),
query: z
.string()
.optional()
.nullable()
.describe("検索クエリ(type=searchの場合に使用)"),
id: z
.number()
.optional()
.nullable()
.describe("取得対象のID(type=fetchの場合に使用)"),
});ネストした union を使うと、LLM がどちらの型を使えばいいか迷って中間的な値を返してくることがあります。オプショナルフィールドに分解してフラットにする方が、実用上は安定します。
複数ツールを管理する実装パターン
ツールが増えてくると、定義とハンドラーの対応を管理するのが大変になります。型安全なファクトリーパターンを使うと整理しやすいです。
type ToolHandler<T extends z.ZodTypeAny> = (
input: z.infer<T>
) => Promise<string>;
function defineTool<T extends z.ZodObject<z.ZodRawShape>>(config: {
name: string;
description: string;
schema: T;
handler: ToolHandler<T>;
}) {
return {
// Anthropic APIに渡すツール定義
definition: {
name: config.name,
description: config.description,
input_schema: zodToJsonSchema(config.schema, {
$refStrategy: "none",
}) as Anthropic.Tool["input_schema"],
},
// 型安全なツール実行
execute: async (rawInput: unknown): Promise<string> => {
const result = config.schema.safeParse(rawInput);
if (!result.success) {
console.error(`[${config.name}] Validation error:`, result.error.format());
return JSON.stringify({ error: "Invalid input", details: result.error.format() });
}
return config.handler(result.data);
},
};
}
// ツールを定義
const weatherTool = defineTool({
name: "get_weather",
description: "指定した都市の天気を取得します",
schema: GetWeatherSchema,
handler: async ({ location, unit }) => {
return JSON.stringify(await fetchWeather(location, unit ?? "celsius"));
},
});
const translateTool = defineTool({
name: "translate",
description: "テキストを指定した言語に翻訳します",
schema: z.object({
text: z.string().describe("翻訳するテキスト"),
target_language: z.string().describe("翻訳先の言語(例: ja, en, fr)"),
}),
handler: async ({ text, target_language }) => {
return JSON.stringify(await translateText(text, target_language));
},
});
// ツール一覧を管理
const tools = [weatherTool, translateTool];
const toolMap = new Map(tools.map((t) => [t.definition.name, t]));
// エージェントループで使う
async function runAgent(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
while (true) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: tools.map((t) => t.definition),
messages,
});
if (response.stop_reason === "end_turn") {
const textBlock = response.content.find((b) => b.type === "text");
return textBlock?.text ?? "";
}
if (response.stop_reason === "tool_use") {
// アシスタントの応答を追加
messages.push({ role: "assistant", content: response.content });
// ツール結果を収集
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const tool = toolMap.get(block.name);
const result = tool
? await tool.execute(block.input)
: JSON.stringify({ error: `Unknown tool: ${block.name}` });
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
messages.push({ role: "user", content: toolResults });
}
}
}このパターンにすると、ツールを追加するときに defineTool を呼ぶだけでよく、定義と処理の対応が常に一致します。handler は純粋な関数なので、Vitest でのユニットテストもモックなしで書けます。
実際に役立った点
Zod を導入してから、本番環境で「LLM が予期しない型を返してきた」系のエラーがほぼなくなりました。それ以上に実感しているのは、safeParse のエラーログが開発時のデバッグに役立つことです。
LLM がどんな値を渡してきたかが result.error.format() で確認できるため、「なぜツールが失敗したか」の原因特定が早くなります。プロンプトの調整にもフィードバックとして使えます。
型安全なツール呼び出しを始める第一歩
まずは既存のツール定義の input_schema を Zod スキーマで書き直すところから始めると、移行コストが少なく済みます。全部を一度に変えなくても、新しく追加するツールから Zod を使うだけで、徐々に型安全な状態に近づいていきます。
defineTool のパターンは少し手間がかかりますが、ツールの数が増えてから整理しようとすると大変です。初期段階で仕組みを入れておくことを、個人的にはおすすめします。
お読みいただきありがとうございました。同じ構成で迷っている方の同じ課題に取り組んでいる方の参考になれば幸いです。