メインコンテンツへスキップ
ブログ一覧に戻る
バックエンド開発

TypeScriptとZodによる型安全なAPIの構築

2023年11月28日
9分で読めます

この記事でわかること

TypeScriptとZodを組み合わせて、実行時も型安全性を担保したREST APIの実装テクニックを解説します。

2026年10月8日にTypeScript HandbookとZodの現行本文を再確認した。TypeScriptの型は開発時の静的検査であり、外部HTTP入力・JSON・DB・環境変数が実行時に正しいとは限らない。Zodなどのスキーマで境界入力を解析し、変換・エラー・認証認可・業務ルールを別に扱い、推論された型を検証済みデータの証拠と取り違えない。APIの型安全は契約、互換性、監視、認可と一緒に評価する。

TypeScriptとZodでAPIを作る:入力検証と型をつなぐ実践ガイド

入力の型を定義したのに、空白だけの名前が保存される。JSONが壊れているだけなのに、サーバー障害として返してしまう。APIの入口では、型を書くことに加えて、受け取った値の条件と失敗時の扱いを決める必要があります。

この記事では、システム間のデータの窓口であるAPIを作る担当者に向けて、Zodで条件を定義し、検証済みの値へつなぐ実装を示します。例はプロフィール情報を検証して返すだけのAPIです。まず入口の振る舞いを確かめ、その後に保存や認可を接続します。

30秒で要点

  • JSONとして読めることと、必要な項目が正しいことは分けて確認する。
  • 外から届く値はunknownで受け、検証が成功したdataを使う。
  • 空白、任意項目、余分な項目、型変換をスキーマの方針として決める。
  • 入力側だけでなく、受信する応答の形が合っているかも考える。
  • 検証の合格は、本人確認・操作権限・保存の成功を意味しない。

型とスキーマは、違う時点で働く

スキーマは、受け入れるデータの形と条件を書いた定義です。Zodはこの定義を使って実際の値を調べ、TypeScriptはそこから推論した型をコードの検査に使えます。

一方、await request.json() as Profileと書いても、プロフィールの中身は検証されません。型アサーションは実行時の検査を追加しないためです。TypeScript公式の説明

リクエスト本文をJSONとして解析し、解析できなければINVALID_JSONを返す。解析できたunknownの値をZodで検証し、条件を満たさなければINVALID_INPUTを返す。成功した場合だけresult.dataを応答に使う。保存と操作権限の確認は別に設計する。
図1:検証専用APIの分岐。JSONの不正と項目の不正を区別し、成功した値だけを次へ渡す。

以下のコードは、2026年10月4日に確認したZod公式ガイドとスキーマAPIに沿っています。z.strictObjectとz.emailを利用できる導入環境を前提にします。既存プロジェクトへ入れる前に、そのプロジェクトの依存関係と対応する公式資料を確認してください。

入力の約束を一か所に書く

例では、名前とメールアドレスを必須にし、年齢は数値で渡す場合だけ受け入れます。名前の前後の空白を除去し、知らない項目は拒否します。これらはプロフィール検証の作例の仕様であり、すべてのサービスの登録条件ではありません。

lib/profile-schema.tsとして使うコードです。

import { z } from "zod";

export const ProfileSchema = z.strictObject({
  name: z.string().trim().min(1).max(80),
  email: z.email(),
  age: z.number().int().min(0).max(130).optional(),
});

export type Profile = z.infer<typeof ProfileSchema>;

trim()を先に適用するので、空白だけの名前は長さの検査に通りません。年齢の0〜130はこの例の受付範囲です。メールは形式の確認であり、所有者の確認ではありません。国際化されたアドレスなどを含め、実際に受け入れる形式もサービス側で決めます。

名前の上限値を画面の「見た目の文字数」と同一視しないことも必要です。絵文字や結合文字を含む入力は、使う検証ライブラリの数え方と画面上の案内を合わせて確認します。

余分な項目と空の値を、意図して扱う

表が収まらない場合は、横にスクロールできます

選ぶもの受け入れ方判断する点
z.object(...)定義にないキーは解析結果から除く追加項目を無視してよい契約か
z.strictObject(...)定義にないキーがあると失敗クライアント側の追加項目も拒否してよいか
.optional()省略やundefinedを許す値がない状態を認めるか
.nullable()nullを許す明示的な空値に意味を持たせるか

この例のageは省略できますが、nullや文字列の"30"は受け入れません。数値へ自動変換したいからとz.coerce.number()に変えると、JavaScriptのNumber(...)に従い空文字やnullも0へ変わり得ます。URLのクエリやフォームの文字列を扱う場合は、空欄をどう扱うかを先に決めます。Zodの型変換とオブジェクトの仕様

JSON解析と項目検証を分けて応答する

Next.js App Routerでは、app配下のroute.tsでWeb標準のRequestとResponseを扱えます。公式Route Handlersガイド

次はapp/api/validate-profile/route.tsに置く例です。先ほどのスキーマをプロジェクト直下のlib/profile-schema.tsへ置いた場合の相対パスを使っています。src/app構成などでは配置に合わせて読み込み先を調整してください。

import { ProfileSchema } from "../../../lib/profile-schema";

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return Response.json(
      { ok: false, code: "INVALID_JSON" },
      { status: 400 },
    );
  }

  const result = ProfileSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      {
        ok: false,
        code: "INVALID_INPUT",
        issues: result.error.issues.map((issue) => ({
          path: issue.path.map(String),
          code: issue.code,
        })),
      },
      { status: 400 },
    );
  }

  return Response.json({ ok: true, profile: result.data });
}

safeParseは、成功時のデータか失敗時のエラーを含む結果を返します。successで分けた後にdataを使うため、型アサーションで押し切る必要がありません。前後の空白を除いた名前も、このdataに含まれます。

このAPIでは二種類の入力不正をHTTP 400で返し、codeで区別します。エラーの本文は本例で決めた契約です。画面で利用する場合は、pathとcodeを日本語の項目別案内へ対応させてください。入力値そのものや内部の例外全文を返す必要はありません。

parse()を使う場合は不一致で例外が発生します。また、非同期の条件や変換をスキーマに含める場合は、await schema.safeParseAsync(...)などが必要です。Zodのエラー処理

合格した値で、何がまだ分からないか

プロフィールの形式が正しくても、他人のプロフィールを変更してよいかは分かりません。検証ルールに書かれていない条件は、検証成功からは導けません。

表が収まらない場合は、横にスクロールできます

確かめたいこと置く処理
入力の形・範囲今回のスキーマによる検証
操作している人と変更対象の関係認証と、操作を許すかの認可
同じメールを登録できるか業務ルールと保存時の制約
保存処理が成功したかデータベース操作の結果と障害処理
入力量や呼び出し回数が許容範囲かサーバー側のサイズ制限・頻度制限

上のコードには保存処理がないため、成功応答は「登録完了」ではありません。保存を追加するときは、入力不正と保存先の障害を分けて扱います。一意性などは事前の確認後にも変わり得るので、保存時の制約を合わせて設計します。

ブラウザでも同じスキーマを使えば入力中に案内できますが、サーバー側の検証は残します。OWASPの入力検証ガイドが説明する通り、ブラウザ側の検証は迂回できます。検証は、SQLのパラメーター化や出力時の適切なエスケープを代わりに行うものでもありません。

応答を受け取る側にも契約を置く

同じリポジトリで型を共有していても、古いクライアント、配布の時間差、途中のサーバーからのエラー応答があります。受信したJSONをそのまま期待する型へ変えると、その境界の確認が抜けます。

次は成功応答だけを読む小さな関数です。lib/read-profile-response.tsとして、先ほどのスキーマと同じフォルダーに置く例です。呼び出し元では、この関数の失敗を画面のエラー状態へつなぎます。

import { z } from "zod";
import { ProfileSchema } from "./profile-schema";

const SuccessResponseSchema = z.object({
  ok: z.literal(true),
  profile: ProfileSchema,
});

export async function readProfileResponse(response: Response) {
  if (!response.ok) {
    throw new Error("プロフィールの検証に失敗しました");
  }

  const body: unknown = await response.json();
  const result = SuccessResponseSchema.safeParse(body);

  if (!result.success) {
    throw new Error("応答の形式が想定と異なります");
  }

  return result.data.profile;
}

ここではエラー応答の項目別案内は扱っていません。必要になれば、成功応答と同様にエラー側の契約も定義します。通信の失敗、HTTPの失敗、応答形式の不一致を、利用者が取れる行動へ結び付けます。

型変換を含むスキーマでは、入力と出力の型が異なる場合もあります。z.input<typeof Schema>は変換前、z.output<typeof Schema>は変換後を表し、z.inferは出力側の型です。受信直後のJSONは、これらの型を注釈するだけで検証済みにはなりません。Zodの型推論

成功する一例だけで終わらせない

まずスキーマ単体と、APIの応答を分けて試します。メールはテスト用のreader@example.comとします。

表が収まらない場合は、横にスクロールできます

渡す内容この例で期待する結果
名前" 太郎 "、メール、年齢30成功し、名前は"太郎"になる
名前が空白だけINVALID_INPUT
メールが"invalid"INVALID_INPUT
年齢を省略成功
年齢がnull、"30"、小数、範囲外INVALID_INPUT
未定義のrole: "admin"を追加INVALID_INPUT
配列やnullを本文にするINVALID_INPUT
閉じ括弧のないJSONINVALID_JSON

応答側には、HTTPエラーと、HTTP成功でもprofileの形が違う場合を渡します。入力の正常化を使っているなら、元のbodyではなく検証結果が返っていることも確かめます。

最初の作業は、一つのAPIについて「受け入れる値」「拒否する値」「失敗の返し方」を書くことです。部品や画面の状態の型設計はフロントエンドでのTypeScript活用、窓口全体の設計はAPI入門へつなげて考えられます。