CLAUDE LABEN
WWDC — WWDC 2026でSiriはGoogle Geminiベースと確定。ChatGPT等への外部ハンドオフは廃止され、サードパーティAI選択はEU(DMA)で当面非対応にBILLING — 6/15の課金変更まで残り6日。Agent SDK・headless Claude Code・GitHub Actions・他社エージェントがAPIレート準拠の月次クレジットへ移行OUTAGE — claude.ai・Claude Code・Coworkで障害が報告(6月)。スケジュール実行はfallbackModelとリトライ前提の設計が安全ですDYNAMIC-WORKFLOWS — Max・TeamプランとAPIでdynamic workflowsがデフォルトON。コードベース横断のバグ探索や独立検証に活用ULTRACODE — Claude Codeの新設定ultracodeがeffortメニューに追加。xhigh固定でワークフロー判断はClaudeに委ねますOPUS4.8 — Claude Opus 4.8が主要プランのデフォルトとして定着。コーディング・エージェント・推論を強化WWDC — WWDC 2026でSiriはGoogle Geminiベースと確定。ChatGPT等への外部ハンドオフは廃止され、サードパーティAI選択はEU(DMA)で当面非対応にBILLING — 6/15の課金変更まで残り6日。Agent SDK・headless Claude Code・GitHub Actions・他社エージェントがAPIレート準拠の月次クレジットへ移行OUTAGE — claude.ai・Claude Code・Coworkで障害が報告(6月)。スケジュール実行はfallbackModelとリトライ前提の設計が安全ですDYNAMIC-WORKFLOWS — Max・TeamプランとAPIでdynamic workflowsがデフォルトON。コードベース横断のバグ探索や独立検証に活用ULTRACODE — Claude Codeの新設定ultracodeがeffortメニューに追加。xhigh固定でワークフロー判断はClaudeに委ねますOPUS4.8 — Claude Opus 4.8が主要プランのデフォルトとして定着。コーディング・エージェント・推論を強化
記事一覧/API & SDK
API & SDK/2026-05-08中級

Claude API のツール呼び出しを Zod で型安全にした — TypeScript 開発者が詰まるポイントと実装パターン

Claude API のツール呼び出しを TypeScript + Zod で型安全に実装する方法。スキーマ自動変換・ランタイムバリデーション・よくある型エラーの解決策を実践コード付きで解説します。

claude-api71typescript18tool-use25zod2型安全3

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 ブロックの inputRecord<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" });
}

safeParseparse の使い分けについて。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 のパターンは少し手間がかかりますが、ツールの数が増えてから整理しようとすると大変です。初期段階で仕組みを入れておくことを、個人的にはおすすめします。

お読みいただきありがとうございました。同じ構成で迷っている方の同じ課題に取り組んでいる方の参考になれば幸いです。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API & SDK2026-03-28
Claude API で Slack Bot を構築する — 業務効率化 AI チャットボットの作り方
Claude APIとSlack Bolt SDKを使って業務効率化AIチャットボットを構築する方法をステップバイステップで解説。メンション応答、スレッド会話、Tool Use連携まで実践コード付き。
API & SDK2026-05-26
Claude API の構造化レスポンスを本番で安定させる — tool_use と JSON Schema、多層検証の実装メモ
Claude API の応答を JSON で受け取る実装は数行で書けますが、本番運用では「形は正しいが意味が壊れている」レスポンスにどう備えるかが分かれ目になります。tool_use・JSON Schema・ドメイン検証を組み合わせた多層パイプラインを、壁紙アプリの分類処理での実体験を交えて整理しました。
API & SDK2026-05-22
Claude API の tool_result could not be submitted が再発しないための回復ハンドラ設計
Claude API でエージェントを長時間動かしていると、ある日突然 'tool_result could not be submitted' が連発し始めることがあります。再試行しても直らない、ストリーミング途中で死ぬ、そして次のセッションでもまた出る — 個人開発で運営している6サイトの自動投稿パイプラインで実際に踏み抜いた4種類の原因と、TypeScript で書いた回復ハンドラの実装を整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →