T-CREATOR

Zod スキーマのバージョニング運用:SemVer・互換レイヤー・段階移行の実践

Zod スキーマのバージョニング運用:SemVer・互換レイヤー・段階移行の実践

実務で Zod スキーマを運用していると、API 仕様の変更やフロントエンド要件の変化によって、スキーマ自体を更新する必要が出てきます。しかし、単純にスキーマを変更してしまうと、既存のコードが壊れたり、クライアントとサーバーの間で不整合が発生したりする危険性があるのです。

そこで重要になるのが、スキーマのバージョニング運用です。セマンティックバージョニング(SemVer)の考え方を取り入れ、互換レイヤーを用意し、段階的に移行を進めることで、安全かつスムーズにスキーマを進化させることができます。

この記事では、Zod スキーマのバージョニング運用について、実践的な手法とコード例を交えて解説していきます。

背景

スキーマ変更が必要になる場面

実際の開発現場では、以下のような理由でスキーマの変更が必要になります。

typescriptimport { z } from 'zod';

// 初期バージョンのスキーマ
const UserSchemaV1 = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

このシンプルなスキーマから始まったプロジェクトでも、要件が増えていくと以下のような変更が必要になるでしょう。

変更理由具体例影響範囲
1新機能追加年齢フィールドの追加
2セキュリティ強化パスワード要件の厳格化
3仕様変更必須フィールドの追加
4データ型の変更日付を string から Date に変更

無計画なスキーマ変更のリスク

スキーマを適切に管理せずに変更を加えると、深刻な問題が発生します。

typescript// 危険な例:既存スキーマを直接変更
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  // 突然必須フィールドを追加
  age: z.number(), // ← 既存データがバリデーションエラーになる!
});

// 既存のデータがバリデーションに失敗する
const existingUser = {
  id: '123',
  name: 'Taro',
  email: 'taro@example.com',
  // age が存在しない
};

const result = UserSchema.safeParse(existingUser);
// result.success === false

このような変更により、以下のような問題が発生してしまいます。

typescript// 問題1: フロントエンドとバックエンドの不整合
// フロントエンド(古いバージョン)
const frontendData = {
  id: '123',
  name: 'Taro',
  email: 'taro@example.com',
};

// バックエンド(新しいバージョン)
UserSchema.parse(frontendData); // エラー発生!
typescript// 問題2: データベースの既存レコードが無効になる
const usersFromDB = await db.users.findMany();
// 既存ユーザーデータにageフィールドがない
usersFromDB.forEach((user) => {
  UserSchema.parse(user); // すべてエラー!
});

スキーマ変更の影響範囲を図で確認してみましょう。

mermaidflowchart TD
  change["スキーマ変更<br/>(無計画)"] --> frontend["フロントエンド<br/>コード"]
  change --> backend["バックエンド<br/>API"]
  change --> db["データベース<br/>レコード"]

  frontend --> error1["バリデーション<br/>エラー"]
  backend --> error2["型不整合<br/>エラー"]
  db --> error3["既存データ<br/>無効化"]

  error1 --> impact["システム障害"]
  error2 --> impact
  error3 --> impact

  style change fill:#ffebee
  style impact fill:#d32f2f,color:#fff

チーム開発での同期問題

複数の開発者が同時に作業している環境では、スキーマ変更の影響はさらに複雑になります。

typescript// 開発者A: 機能Aを実装中
const FeatureASchema = z.object({
  userId: z.string(),
  preferences: z.object({
    theme: z.enum(['light', 'dark']),
  }),
});

// 開発者B: 同時に機能Bを実装
const FeatureBSchema = z.object({
  userId: z.string(),
  preferences: z.object({
    language: z.enum(['ja', 'en']),
  }),
});

// マージ時に衝突!どちらのpreferencesが正しい?

このようなチーム開発における課題として、以下が挙げられます。

  1. 変更の追跡が困難 - どの時点でどのフィールドが追加・削除されたか不明
  2. ブランチ間の不整合 - 異なるブランチで異なるスキーマバージョンが存在
  3. リリースタイミングの調整 - フロントとバックエンドのデプロイ順序に依存

課題

破壊的変更と非破壊的変更の見極め

スキーマの変更には、**破壊的変更(Breaking Change)非破壊的変更(Non-Breaking Change)**の 2 種類があります。この違いを理解することが、バージョニング運用の基礎となります。

非破壊的変更の例

typescript// バージョン1.0.0
const UserSchemaV1_0_0 = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// バージョン1.1.0(非破壊的変更)
const UserSchemaV1_1_0 = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  // オプショナルフィールドの追加
  age: z.number().optional(),
  // デフォルト値付きフィールドの追加
  createdAt: z
    .string()
    .default(() => new Date().toISOString()),
});

// 既存データも引き続き有効
const oldData = {
  id: '123',
  name: 'Taro',
  email: 'taro@example.com',
};

UserSchemaV1_1_0.parse(oldData); // 成功!

破壊的変更の例

typescript// バージョン1.0.0
const ProductSchemaV1 = z.object({
  id: z.string(),
  price: z.number(), // 数値型
});

// バージョン2.0.0(破壊的変更)
const ProductSchemaV2 = z.object({
  id: z.string(),
  // 型を変更
  price: z.object({
    amount: z.number(),
    currency: z.string(),
  }),
  // 必須フィールドの追加
  category: z.string(),
  // フィールド名の変更
  productId: z.string(), // id → productId
});

// 既存データが無効になる
const oldProduct = {
  id: 'P001',
  price: 1000,
};

ProductSchemaV2.safeParse(oldProduct); // 失敗!

変更種別の判定基準を表にまとめます。

| 変更内容 | 種別 | 既存データへの影響 | | -------- | ---------------------------- | ------------------ | -------------------- | | 1 | オプショナルフィールドの追加 | 非破壊的 | なし | | 2 | 必須フィールドの追加 | 破壊的 | バリデーションエラー | | 3 | フィールドの削除 | 破壊的 | 型エラー | | 4 | 型の変更 | 破壊的 | バリデーションエラー | | 5 | バリデーションルールの緩和 | 非破壊的 | なし | | 6 | バリデーションルールの厳格化 | 破壊的 | 既存データが不正に |

複数バージョン同時運用の複雑さ

実際のサービス運用では、複数のスキーマバージョンを同時にサポートする必要があります。

typescript// クライアントAは古いバージョンを使用
const clientAData = {
  id: '123',
  name: 'Taro',
  email: 'taro@example.com',
};

// クライアントBは新しいバージョンを使用
const clientBData = {
  id: '456',
  name: 'Hanako',
  email: 'hanako@example.com',
  age: 25,
  preferences: {
    theme: 'dark',
  },
};

// サーバーは両方を受け入れる必要がある

複数バージョン同時運用における課題を以下に示します。

typescript// 課題1: バージョンの識別
function handleUserData(data: unknown) {
  // どのバージョンのスキーマでバリデーションすべき?
  if (isV1Format(data)) {
    return UserSchemaV1.parse(data);
  } else if (isV2Format(data)) {
    return UserSchemaV2.parse(data);
  }
  // バージョン判定ロジックが複雑化
}
typescript// 課題2: データ変換処理
function convertV1ToV2(v1Data: UserV1): UserV2 {
  return {
    ...v1Data,
    age: undefined, // 新フィールドのデフォルト値
    preferences: {
      theme: 'light',
    },
  };
}

// 変換関数が増え続ける
function convertV2ToV3(v2Data: UserV2): UserV3 {
  // ...
}

移行期間中の後方互換性の維持

スキーマを更新する際、すべてのクライアントが同時に新バージョンに移行できるわけではありません。移行期間中は、古いバージョンと新しいバージョンの両方をサポートする必要があります。

typescript// 問題: 新旧両方のリクエストに対応する必要がある
async function createUser(request: Request) {
  const data = await request.json();

  // V1クライアントからのリクエスト
  // { name: "Taro", email: "taro@example.com" }

  // V2クライアントからのリクエスト
  // { name: "Taro", email: "taro@example.com", age: 25 }

  // どちらも受け入れつつ、内部では統一した形式で処理したい
}

移行期間における処理フローを図で表現します。

mermaidsequenceDiagram
  participant OldClient as 旧クライアント<br/>(V1)
  participant NewClient as 新クライアント<br/>(V2)
  participant API as API サーバー
  participant DB as データベース

  OldClient->>API: V1形式でリクエスト
  API->>API: V1 → V2 変換
  API->>DB: V2形式で保存
  DB->>API: データ返却
  API->>API: V2 → V1 変換
  API->>OldClient: V1形式でレスポンス

  NewClient->>API: V2形式でリクエスト
  API->>DB: そのまま保存
  DB->>API: データ返却
  API->>NewClient: V2形式でレスポンス

このように、移行期間中は双方向の変換処理が必要になり、システムの複雑性が増大してしまいます。

解決策

セマンティックバージョニング(SemVer)の適用

セマンティックバージョニングは、ソフトウェアのバージョン管理における業界標準の手法です。MAJOR.MINOR.PATCHの形式でバージョンを表現します。

バージョン番号の決定ルール

typescriptimport { z } from 'zod';

// MAJOR.MINOR.PATCH
// 1.0.0 → 初期バージョン

// PATCH: バグ修正(互換性あり)
const UserSchemaV1_0_0 = z.object({
  name: z.string().min(1), // 最小文字数なし
  email: z.string().email(),
});

const UserSchemaV1_0_1 = z.object({
  name: z.string().min(1, '名前は必須です'), // エラーメッセージ追加
  email: z.string().email(),
});
typescript// MINOR: 機能追加(後方互換性あり)
const UserSchemaV1_1_0 = z.object({
  name: z.string().min(1, '名前は必須です'),
  email: z.string().email(),
  // オプショナルフィールド追加
  age: z.number().optional(),
  phoneNumber: z.string().optional(),
});
typescript// MAJOR: 破壊的変更(後方互換性なし)
const UserSchemaV2_0_0 = z.object({
  name: z.string().min(1, '名前は必須です'),
  email: z.string().email(),
  // 必須フィールドに変更
  age: z.number(),
  // 型構造の変更
  contact: z.object({
    phone: z.string(),
    address: z.string(),
  }),
});

バージョン管理のベストプラクティスを以下にまとめます。

| バージョン種別 | インクリメント条件 | 例 | | -------------- | ------------------ | ------------------------------ | ------------- | | 1 | PATCH | バリデーションメッセージの改善 | 1.0.0 → 1.0.1 | | 2 | MINOR | オプショナルフィールド追加 | 1.0.0 → 1.1.0 | | 3 | MAJOR | 必須フィールド追加 | 1.2.0 → 2.0.0 | | 4 | MAJOR | フィールドの削除 | 1.2.0 → 2.0.0 | | 5 | MAJOR | 型の変更 | 1.2.0 → 2.0.0 |

スキーマバージョンの実装パターン

typescript// schemas/user/index.ts
import { z } from 'zod';

// バージョン管理用の名前空間
export namespace UserSchema {
  // バージョン1.0.0
  export const v1_0_0 = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
  });

  // バージョン1.1.0
  export const v1_1_0 = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
    age: z.number().optional(),
  });

  // バージョン2.0.0
  export const v2_0_0 = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
    age: z.number(),
    profile: z.object({
      bio: z.string(),
      avatar: z.string().url(),
    }),
  });

  // 最新バージョンへのエイリアス
  export const latest = v2_0_0;

  // 型定義のエクスポート
  export type V1_0_0 = z.infer<typeof v1_0_0>;
  export type V1_1_0 = z.infer<typeof v1_1_0>;
  export type V2_0_0 = z.infer<typeof v2_0_0>;
  export type Latest = z.infer<typeof latest>;
}

互換レイヤーの設計と実装

互換レイヤーは、異なるバージョン間のデータ変換を行う中間層です。これにより、クライアントが古いバージョンを使い続けても、サーバー側で最新の形式に変換できます。

バージョン変換関数の実装

typescript// converters/userSchemaConverters.ts
import { UserSchema } from '../schemas/user';

// V1からV2への変換
export function convertUserV1ToV2(
  v1Data: UserSchema.V1_0_0
): UserSchema.V2_0_0 {
  return {
    ...v1Data,
    // 新規必須フィールドにデフォルト値を設定
    age: 0,
    profile: {
      bio: '',
      avatar: 'https://example.com/default-avatar.png',
    },
  };
}
typescript// V2からV1への変換(ダウングレード)
export function convertUserV2ToV1(
  v2Data: UserSchema.V2_0_0
): UserSchema.V1_0_0 {
  // 新しいフィールドを除去
  const { age, profile, ...v1Data } = v2Data;
  return v1Data;
}
typescript// V1.1からV2への変換
export function convertUserV1_1ToV2(
  v1_1Data: UserSchema.V1_1_0
): UserSchema.V2_0_0 {
  return {
    ...v1_1Data,
    // ageがoptionalなので、存在チェック
    age: v1_1Data.age ?? 0,
    profile: {
      bio: '',
      avatar: 'https://example.com/default-avatar.png',
    },
  };
}

汎用的なバージョン管理システム

typescript// core/schemaVersionManager.ts
import { z } from 'zod';

// バージョン情報の型定義
interface SchemaVersion<T> {
  version: string;
  schema: z.ZodType<T>;
  deprecated?: boolean;
  deprecationMessage?: string;
}

// バージョン間の変換関数の型
type Converter<From, To> = (data: From) => To;

// スキーマバージョンマネージャー
export class SchemaVersionManager<T> {
  private versions: Map<string, SchemaVersion<any>> =
    new Map();
  private converters: Map<string, Converter<any, any>> =
    new Map();

  // バージョンの登録
  registerVersion<V>(
    version: string,
    schema: z.ZodType<V>,
    options?: {
      deprecated?: boolean;
      deprecationMessage?: string;
    }
  ) {
    this.versions.set(version, {
      version,
      schema,
      deprecated: options?.deprecated,
      deprecationMessage: options?.deprecationMessage,
    });
    return this;
  }

  // 変換関数の登録
  registerConverter<From, To>(
    fromVersion: string,
    toVersion: string,
    converter: Converter<From, To>
  ) {
    const key = `${fromVersion}->${toVersion}`;
    this.converters.set(key, converter);
    return this;
  }

  // バージョンの取得
  getVersion(
    version: string
  ): SchemaVersion<any> | undefined {
    return this.versions.get(version);
  }

  // データのバリデーションと変換
  parseAndConvert<V>(
    data: unknown,
    fromVersion: string,
    toVersion: string
  ): V {
    // 元バージョンでバリデーション
    const fromSchema = this.versions.get(fromVersion);
    if (!fromSchema) {
      throw new Error(
        `Schema version ${fromVersion} not found`
      );
    }

    const validatedData = fromSchema.schema.parse(data);

    // 同じバージョンならそのまま返す
    if (fromVersion === toVersion) {
      return validatedData as V;
    }

    // 変換関数を取得
    const converterKey = `${fromVersion}->${toVersion}`;
    const converter = this.converters.get(converterKey);

    if (!converter) {
      throw new Error(
        `Converter from ${fromVersion} to ${toVersion} not found`
      );
    }

    // 変換実行
    const convertedData = converter(validatedData);

    // 変換後のバージョンでバリデーション
    const toSchema = this.versions.get(toVersion);
    if (!toSchema) {
      throw new Error(
        `Schema version ${toVersion} not found`
      );
    }

    return toSchema.schema.parse(convertedData) as V;
  }

  // 非推奨バージョンの警告
  checkDeprecation(version: string): void {
    const schemaVersion = this.versions.get(version);
    if (schemaVersion?.deprecated) {
      console.warn(
        `Warning: Schema version ${version} is deprecated. ${
          schemaVersion.deprecationMessage || ''
        }`
      );
    }
  }
}

バージョンマネージャーの使用例

typescript// schemas/user/versionManager.ts
import { SchemaVersionManager } from '../../core/schemaVersionManager';
import { UserSchema } from './index';
import {
  convertUserV1ToV2,
  convertUserV2ToV1,
  convertUserV1_1ToV2,
} from '../../converters/userSchemaConverters';

// ユーザースキーマのバージョン管理インスタンス
export const userSchemaManager =
  new SchemaVersionManager<UserSchema.Latest>()
    .registerVersion('1.0.0', UserSchema.v1_0_0, {
      deprecated: true,
      deprecationMessage:
        'Please migrate to version 2.0.0 by 2025-12-31',
    })
    .registerVersion('1.1.0', UserSchema.v1_1_0, {
      deprecated: true,
      deprecationMessage:
        'Please migrate to version 2.0.0 by 2025-12-31',
    })
    .registerVersion('2.0.0', UserSchema.v2_0_0)
    .registerConverter('1.0.0', '2.0.0', convertUserV1ToV2)
    .registerConverter('2.0.0', '1.0.0', convertUserV2ToV1)
    .registerConverter(
      '1.1.0',
      '2.0.0',
      convertUserV1_1ToV2
    );

バージョン管理システムの構造を図で表現します。

mermaidflowchart LR
  v1["V1.0.0<br/>スキーマ"] --> converter1["変換<br/>V1→V2"]
  v1_1["V1.1.0<br/>スキーマ"] --> converter2["変換<br/>V1.1→V2"]

  converter1 --> v2["V2.0.0<br/>スキーマ(最新)"]
  converter2 --> v2

  v2 --> reverter["逆変換<br/>V2→V1"]
  reverter --> v1

  manager["Version<br/>Manager"] -.管理.- v1
  manager -.管理.- v1_1
  manager -.管理.- v2
  manager -.管理.- converter1
  manager -.管理.- converter2

  style v2 fill:#e8f5e8
  style manager fill:#e3f2fd

段階的移行戦略の立案

スキーマの破壊的変更を行う際は、段階的な移行戦略が重要です。一度にすべてを切り替えるのではなく、複数のフェーズに分けて進めることでリスクを最小化できます。

3 段階移行モデル

typescript// フェーズ1: 準備期間(両バージョン並行サポート開始)
export const MIGRATION_PHASE = {
  PREPARATION: 'preparation', // 新バージョン導入
  TRANSITION: 'transition', // 移行促進
  COMPLETION: 'completion', // 旧バージョン廃止
} as const;

type MigrationPhase =
  (typeof MIGRATION_PHASE)[keyof typeof MIGRATION_PHASE];

// 現在のフェーズを管理
let currentPhase: MigrationPhase =
  MIGRATION_PHASE.PREPARATION;

export function setMigrationPhase(phase: MigrationPhase) {
  currentPhase = phase;
}

export function getMigrationPhase(): MigrationPhase {
  return currentPhase;
}
typescript// フェーズ別の処理ロジック
export function handleUserRequest(
  data: unknown,
  clientVersion: string
): UserSchema.Latest {
  const phase = getMigrationPhase();

  switch (phase) {
    case MIGRATION_PHASE.PREPARATION:
      // 準備期間: 両バージョンをサポート
      if (
        clientVersion === '1.0.0' ||
        clientVersion === '1.1.0'
      ) {
        console.log(
          'V1クライアントを検出。V2に変換します。'
        );
        return userSchemaManager.parseAndConvert(
          data,
          clientVersion,
          '2.0.0'
        );
      }
      return UserSchema.v2_0_0.parse(data);

    case MIGRATION_PHASE.TRANSITION:
      // 移行期間: V1は警告付きでサポート
      if (
        clientVersion === '1.0.0' ||
        clientVersion === '1.1.0'
      ) {
        console.warn(
          `警告: バージョン${clientVersion}は非推奨です。2.0.0に移行してください。`
        );
        userSchemaManager.checkDeprecation(clientVersion);
        return userSchemaManager.parseAndConvert(
          data,
          clientVersion,
          '2.0.0'
        );
      }
      return UserSchema.v2_0_0.parse(data);

    case MIGRATION_PHASE.COMPLETION:
      // 完了期間: V2のみサポート
      if (clientVersion !== '2.0.0') {
        throw new Error(
          `バージョン${clientVersion}はサポートされていません。2.0.0にアップグレードしてください。`
        );
      }
      return UserSchema.v2_0_0.parse(data);

    default:
      throw new Error(`Unknown migration phase: ${phase}`);
  }
}

移行スケジュールの例

typescript// migration/schedule.ts

// 移行スケジュールの定義
export const MIGRATION_SCHEDULE = {
  phases: [
    {
      phase: MIGRATION_PHASE.PREPARATION,
      startDate: new Date('2025-01-01'),
      endDate: new Date('2025-03-31'),
      description: 'V2を導入。V1とV2を並行サポート。',
      actions: [
        'V2スキーマのリリース',
        'ドキュメント更新',
        'クライアント向け移行ガイド公開',
      ],
    },
    {
      phase: MIGRATION_PHASE.TRANSITION,
      startDate: new Date('2025-04-01'),
      endDate: new Date('2025-09-30'),
      description: 'V1非推奨警告を表示。移行を促進。',
      actions: [
        'V1使用時に警告ログ出力',
        'ダッシュボードでバージョン分布表示',
        '個別クライアントへの移行サポート',
      ],
    },
    {
      phase: MIGRATION_PHASE.COMPLETION,
      startDate: new Date('2025-10-01'),
      endDate: null, // 終了日なし(恒久的)
      description: 'V1サポート終了。V2のみサポート。',
      actions: [
        'V1リクエストをエラーとして拒否',
        'V1関連コードの削除',
        'V2を新しいベースラインとして確立',
      ],
    },
  ],
} as const;
typescript// 現在のフェーズを自動判定
export function getCurrentMigrationPhase(): MigrationPhase {
  const now = new Date();

  for (const phaseInfo of MIGRATION_SCHEDULE.phases) {
    if (
      now >= phaseInfo.startDate &&
      (phaseInfo.endDate === null ||
        now <= phaseInfo.endDate)
    ) {
      return phaseInfo.phase;
    }
  }

  // デフォルトは準備期間
  return MIGRATION_PHASE.PREPARATION;
}

移行スケジュールのタイムラインを図で表現します。

mermaidgantt
    title スキーマ移行タイムライン
    dateFormat YYYY-MM-DD

    section フェーズ1: 準備期間
    V2導入・並行サポート   :prep1, 2025-01-01, 90d
    ドキュメント整備       :prep2, 2025-01-01, 30d
    移行ガイド公開         :prep3, 2025-01-15, 15d

    section フェーズ2: 移行期間
    V1非推奨警告表示       :trans1, 2025-04-01, 183d
    クライアント移行支援   :trans2, 2025-04-01, 150d
    バージョン分布監視     :trans3, 2025-04-01, 183d

    section フェーズ3: 完了期間
    V1サポート終了         :comp1, 2025-10-01, 30d
    V1コード削除           :comp2, 2025-10-15, 15d

具体例

API エンドポイントでのバージョン管理

実際の API エンドポイントで、複数のスキーマバージョンをサポートする実装例を見ていきましょう。

バージョン指定方法の実装

typescript// api/users/route.ts(Next.js App Router の例)
import { NextRequest, NextResponse } from 'next/server';
import { userSchemaManager } from '@/schemas/user/versionManager';
import { UserSchema } from '@/schemas/user';

export async function POST(request: NextRequest) {
  try {
    // ヘッダーからバージョンを取得
    const apiVersion =
      request.headers.get('X-API-Version') || '2.0.0';

    // リクエストボディの取得
    const body = await request.json();

    // バージョンを確認
    userSchemaManager.checkDeprecation(apiVersion);

    // バリデーションと変換
    const userData =
      userSchemaManager.parseAndConvert<UserSchema.Latest>(
        body,
        apiVersion,
        '2.0.0' // 内部では常に最新バージョンで処理
      );

    // ユーザー作成処理
    const createdUser = await createUserInDB(userData);

    // レスポンスを元のバージョン形式に変換
    const responseData =
      apiVersion === '2.0.0'
        ? createdUser
        : userSchemaManager.parseAndConvert(
            createdUser,
            '2.0.0',
            apiVersion
          );

    return NextResponse.json(responseData, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        {
          error: 'Validation Error',
          details: error.errors,
        },
        { status: 400 }
      );
    }

    return NextResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }
}

URL パスベースのバージョニング

typescript// api/v1/users/route.ts
export async function POST(request: NextRequest) {
  const body = await request.json();

  // V1として処理
  const userData =
    userSchemaManager.parseAndConvert<UserSchema.Latest>(
      body,
      '1.0.0',
      '2.0.0'
    );

  const result = await createUserInDB(userData);

  // V1形式でレスポンス
  const v1Response = userSchemaManager.parseAndConvert(
    result,
    '2.0.0',
    '1.0.0'
  );

  return NextResponse.json(v1Response);
}
typescript// api/v2/users/route.ts
export async function POST(request: NextRequest) {
  const body = await request.json();

  // V2として処理
  const userData = UserSchema.v2_0_0.parse(body);

  const result = await createUserInDB(userData);

  // V2形式でレスポンス
  return NextResponse.json(result);
}

API バージョニングのフローを図で確認します。

mermaidsequenceDiagram
  participant Client as クライアント
  participant API as API層
  participant Manager as Version<br/>Manager
  participant DB as データベース

  Client->>API: POST /api/users<br/>X-API-Version: 1.0.0
  API->>Manager: parseAndConvert<br/>(data, "1.0.0", "2.0.0")
  Manager->>Manager: V1でバリデーション
  Manager->>Manager: V1→V2変換
  Manager->>Manager: V2でバリデーション
  Manager->>API: V2形式データ
  API->>DB: データ保存(V2形式)
  DB->>API: 保存完了
  API->>Manager: convertToVersion<br/>(result, "1.0.0")
  Manager->>API: V1形式データ
  API->>Client: レスポンス(V1形式)

フロントエンドでのスキーマ共有

フロントエンドとバックエンドでスキーマ定義を共有することで、型安全性を保ちながら一貫したバリデーションを実現できます。

モノレポ構成でのスキーマ共有

typescript// packages/shared/schemas/user.ts
import { z } from 'zod';

export namespace UserSchema {
  export const v1_0_0 = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
  });

  export const v2_0_0 = z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email(),
    age: z.number(),
    profile: z.object({
      bio: z.string(),
      avatar: z.string().url(),
    }),
  });

  export type V1_0_0 = z.infer<typeof v1_0_0>;
  export type V2_0_0 = z.infer<typeof v2_0_0>;
}

フロントエンドでの使用例

typescript// apps/frontend/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { UserSchema } from '@shared/schemas/user';

export function UserFormV2() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserSchema.V2_0_0>({
    resolver: zodResolver(UserSchema.v2_0_0),
  });

  const onSubmit = async (data: UserSchema.V2_0_0) => {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Version': '2.0.0',
        },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        throw new Error('ユーザー作成に失敗しました');
      }

      const result = await response.json();
      console.log('ユーザー作成成功:', result);
    } catch (error) {
      console.error('エラー:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>名前</label>
        <input {...register('name')} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>

      <div>
        <label>メールアドレス</label>
        <input type='email' {...register('email')} />
        {errors.email && (
          <span>{errors.email.message}</span>
        )}
      </div>

      <div>
        <label>年齢</label>
        <input
          type='number'
          {...register('age', { valueAsNumber: true })}
        />
        {errors.age && <span>{errors.age.message}</span>}
      </div>

      <div>
        <label>自己紹介</label>
        <textarea {...register('profile.bio')} />
        {errors.profile?.bio && (
          <span>{errors.profile.bio.message}</span>
        )}
      </div>

      <div>
        <label>アバターURL</label>
        <input {...register('profile.avatar')} />
        {errors.profile?.avatar && (
          <span>{errors.profile.avatar.message}</span>
        )}
      </div>

      <button type='submit'>登録</button>
    </form>
  );
}

バージョン自動検出機能

typescript// lib/apiClient.ts
import { z } from 'zod';

// APIクライアントの設定
export class VersionedApiClient {
  private apiVersion: string;
  private baseUrl: string;

  constructor(options: {
    apiVersion: string;
    baseUrl: string;
  }) {
    this.apiVersion = options.apiVersion;
    this.baseUrl = options.baseUrl;
  }

  async post<TRequest, TResponse>(
    endpoint: string,
    data: TRequest,
    requestSchema: z.ZodType<TRequest>,
    responseSchema: z.ZodType<TResponse>
  ): Promise<TResponse> {
    // リクエストデータのバリデーション
    const validatedData = requestSchema.parse(data);

    const response = await fetch(
      `${this.baseUrl}${endpoint}`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Version': this.apiVersion,
        },
        body: JSON.stringify(validatedData),
      }
    );

    if (!response.ok) {
      throw new Error(`API Error: ${response.statusText}`);
    }

    const responseData = await response.json();

    // レスポンスデータのバリデーション
    return responseSchema.parse(responseData);
  }
}
typescript// 使用例
import { UserSchema } from '@shared/schemas/user';

const apiClient = new VersionedApiClient({
  apiVersion: '2.0.0',
  baseUrl: 'https://api.example.com',
});

// 型安全なAPIリクエスト
const newUser = await apiClient.post(
  '/users',
  {
    id: '123',
    name: 'Taro',
    email: 'taro@example.com',
    age: 25,
    profile: {
      bio: 'Hello!',
      avatar: 'https://example.com/avatar.png',
    },
  },
  UserSchema.v2_0_0, // リクエストスキーマ
  UserSchema.v2_0_0 // レスポンススキーマ
);

データベースマイグレーションとの連携

スキーマバージョンの変更は、データベーススキーマの変更とも連携する必要があります。

Prisma との統合例

typescript// prisma/schema.prisma

model User {
  id        String   @id @default(uuid())
  name      String
  email     String   @unique
  // V2で追加されたフィールド
  age       Int?     // nullable にして後方互換性を保つ
  bio       String?
  avatar    String?

  // バージョン管理用フィールド
  schemaVersion String @default("2.0.0")

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

マイグレーション処理の実装

typescript// scripts/migrateUserData.ts
import { PrismaClient } from '@prisma/client';
import { UserSchema } from '../schemas/user';
import { convertUserV1ToV2 } from '../converters/userSchemaConverters';

const prisma = new PrismaClient();

async function migrateUsersToV2() {
  console.log('ユーザーデータのV2への移行を開始します...');

  // V1ユーザーを取得
  const v1Users = await prisma.user.findMany({
    where: {
      schemaVersion: '1.0.0',
    },
  });

  console.log(`V1ユーザー: ${v1Users.length}件`);

  let successCount = 0;
  let errorCount = 0;

  for (const user of v1Users) {
    try {
      // V1データとして検証
      const v1Data = UserSchema.v1_0_0.parse({
        id: user.id,
        name: user.name,
        email: user.email,
      });

      // V2に変換
      const v2Data = convertUserV1ToV2(v1Data);

      // データベースを更新
      await prisma.user.update({
        where: { id: user.id },
        data: {
          age: v2Data.age,
          bio: v2Data.profile.bio,
          avatar: v2Data.profile.avatar,
          schemaVersion: '2.0.0',
        },
      });

      successCount++;
      console.log(`✓ ユーザー ${user.id} を移行しました`);
    } catch (error) {
      errorCount++;
      console.error(
        `✗ ユーザー ${user.id} の移行に失敗:`,
        error
      );
    }
  }

  console.log(
    `\n移行完了: 成功 ${successCount}件, 失敗 ${errorCount}件`
  );
}

// スクリプト実行
migrateUsersToV2()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

バッチ移行とリアルタイム移行の併用

typescript// services/userService.ts
import { PrismaClient } from '@prisma/client';
import { UserSchema } from '../schemas/user';
import { convertUserV1ToV2 } from '../converters/userSchemaConverters';

const prisma = new PrismaClient();

// ユーザー取得時に自動的にマイグレーション
export async function getUserById(
  userId: string
): Promise<UserSchema.V2_0_0> {
  const user = await prisma.user.findUnique({
    where: { id: userId },
  });

  if (!user) {
    throw new Error('User not found');
  }

  // V1ユーザーの場合は自動的にV2に変換
  if (user.schemaVersion === '1.0.0') {
    console.log(`ユーザー ${userId} をV2に自動移行します`);

    const v1Data = {
      id: user.id,
      name: user.name,
      email: user.email,
    };

    const v2Data = convertUserV1ToV2(v1Data);

    // データベースを更新
    const updatedUser = await prisma.user.update({
      where: { id: userId },
      data: {
        age: v2Data.age,
        bio: v2Data.profile.bio,
        avatar: v2Data.profile.avatar,
        schemaVersion: '2.0.0',
      },
    });

    return {
      id: updatedUser.id,
      name: updatedUser.name,
      email: updatedUser.email,
      age: updatedUser.age!,
      profile: {
        bio: updatedUser.bio!,
        avatar: updatedUser.avatar!,
      },
    };
  }

  // V2ユーザーはそのまま返す
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    age: user.age!,
    profile: {
      bio: user.bio!,
      avatar: user.avatar!,
    },
  };
}

データベースマイグレーションの戦略を図で整理します。

mermaidflowchart TD
  start[マイグレーション開始] --> choice{移行方法の選択}

  choice -->|バッチ移行| batch[全ユーザーを<br/>一括移行]
  choice -->|リアルタイム移行| realtime[アクセス時に<br/>自動移行]

  batch --> batchProcess[V1ユーザー抽出]
  batchProcess --> batchConvert[V1→V2変換]
  batchConvert --> batchUpdate[DB一括更新]
  batchUpdate --> batchComplete[移行完了]

  realtime --> access[ユーザーアクセス]
  access --> check{バージョン<br/>確認}
  check -->|V1| convert[V1→V2変換]
  check -->|V2| return[そのまま返却]
  convert --> update[DB更新]
  update --> return

  style batch fill:#fff3e0
  style realtime fill:#e8f5e8
  style batchComplete fill:#e3f2fd
  style return fill:#e3f2fd

テストコードでのバージョン検証

スキーマバージョンの互換性を保証するため、包括的なテストが不可欠です。

バージョン間の変換テスト

typescript// tests/userSchemaConversion.test.ts
import { describe, it, expect } from 'vitest';
import { UserSchema } from '../schemas/user';
import {
  convertUserV1ToV2,
  convertUserV2ToV1,
} from '../converters/userSchemaConverters';

describe('ユーザースキーマ変換', () => {
  describe('V1からV2への変換', () => {
    it('V1データを正しくV2に変換できる', () => {
      const v1Data: UserSchema.V1_0_0 = {
        id: '123',
        name: 'Taro',
        email: 'taro@example.com',
      };

      const v2Data = convertUserV1ToV2(v1Data);

      expect(v2Data).toEqual({
        id: '123',
        name: 'Taro',
        email: 'taro@example.com',
        age: 0,
        profile: {
          bio: '',
          avatar: 'https://example.com/default-avatar.png',
        },
      });
    });

    it('変換後のデータがV2スキーマでバリデーション成功する', () => {
      const v1Data: UserSchema.V1_0_0 = {
        id: '456',
        name: 'Hanako',
        email: 'hanako@example.com',
      };

      const v2Data = convertUserV1ToV2(v1Data);
      const result = UserSchema.v2_0_0.safeParse(v2Data);

      expect(result.success).toBe(true);
    });
  });

  describe('V2からV1への変換', () => {
    it('V2データを正しくV1にダウングレードできる', () => {
      const v2Data: UserSchema.V2_0_0 = {
        id: '789',
        name: 'Jiro',
        email: 'jiro@example.com',
        age: 30,
        profile: {
          bio: 'Software Engineer',
          avatar: 'https://example.com/jiro.png',
        },
      };

      const v1Data = convertUserV2ToV1(v2Data);

      expect(v1Data).toEqual({
        id: '789',
        name: 'Jiro',
        email: 'jiro@example.com',
      });
    });

    it('変換後のデータがV1スキーマでバリデーション成功する', () => {
      const v2Data: UserSchema.V2_0_0 = {
        id: '101',
        name: 'Saburo',
        email: 'saburo@example.com',
        age: 25,
        profile: {
          bio: 'Designer',
          avatar: 'https://example.com/saburo.png',
        },
      };

      const v1Data = convertUserV2ToV1(v2Data);
      const result = UserSchema.v1_0_0.safeParse(v1Data);

      expect(result.success).toBe(true);
    });
  });

  describe('往復変換の整合性', () => {
    it('V1→V2→V1の往復変換で元のデータが保持される', () => {
      const originalV1Data: UserSchema.V1_0_0 = {
        id: '999',
        name: 'Shiro',
        email: 'shiro@example.com',
      };

      const v2Data = convertUserV1ToV2(originalV1Data);
      const backToV1Data = convertUserV2ToV1(v2Data);

      expect(backToV1Data).toEqual(originalV1Data);
    });
  });
});

API エンドポイントの統合テスト

typescript// tests/api/users.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../app';

describe('POST /api/users - バージョン別テスト', () => {
  describe('V1 APIリクエスト', () => {
    it('V1形式のデータで正常にユーザーを作成できる', async () => {
      const v1Request = {
        id: 'v1-user-001',
        name: 'V1 User',
        email: 'v1user@example.com',
      };

      const response = await request(app)
        .post('/api/users')
        .set('X-API-Version', '1.0.0')
        .send(v1Request)
        .expect(201);

      expect(response.body).toMatchObject({
        id: 'v1-user-001',
        name: 'V1 User',
        email: 'v1user@example.com',
      });

      // V2フィールドは含まれない
      expect(response.body.age).toBeUndefined();
      expect(response.body.profile).toBeUndefined();
    });

    it('V1で非推奨警告が出力される', async () => {
      const consoleSpy = vi.spyOn(console, 'warn');

      await request(app)
        .post('/api/users')
        .set('X-API-Version', '1.0.0')
        .send({
          id: 'v1-user-002',
          name: 'Test User',
          email: 'test@example.com',
        });

      expect(consoleSpy).toHaveBeenCalledWith(
        expect.stringContaining('非推奨')
      );
    });
  });

  describe('V2 APIリクエスト', () => {
    it('V2形式のデータで正常にユーザーを作成できる', async () => {
      const v2Request = {
        id: 'v2-user-001',
        name: 'V2 User',
        email: 'v2user@example.com',
        age: 28,
        profile: {
          bio: 'Hello from V2',
          avatar: 'https://example.com/avatar.png',
        },
      };

      const response = await request(app)
        .post('/api/users')
        .set('X-API-Version', '2.0.0')
        .send(v2Request)
        .expect(201);

      expect(response.body).toEqual(v2Request);
    });

    it('必須フィールドが不足している場合はエラーを返す', async () => {
      const invalidRequest = {
        id: 'v2-user-002',
        name: 'Incomplete User',
        email: 'incomplete@example.com',
        // age と profile が不足
      };

      const response = await request(app)
        .post('/api/users')
        .set('X-API-Version', '2.0.0')
        .send(invalidRequest)
        .expect(400);

      expect(response.body.error).toBe('Validation Error');
      expect(response.body.details).toBeDefined();
    });
  });
});

まとめ

スキーマバージョニング運用のベストプラクティス

この記事を通じて、Zod スキーマのバージョニング運用について学んできました。効果的なバージョニング運用のポイントをまとめましょう。

ポイント内容メリット
1SemVer の適用変更の影響範囲が明確になる
2互換レイヤーの実装段階的な移行が可能になる
3バージョンマネージャーの導入変換処理を一元管理できる
43 段階移行モデルリスクを最小化できる
5テストの充実バージョン間の整合性を保証できる

運用時の注意点

typescript// よい例: 非破壊的な変更
const UserSchemaV1_1 = z.object({
  name: z.string(),
  email: z.string().email(),
  // オプショナルフィールドの追加
  age: z.number().optional(),
});

// 避けるべき例: 突然の破壊的変更
const UserSchemaV2_BAD = z.object({
  name: z.string(),
  email: z.string().email(),
  // いきなり必須フィールドを追加
  age: z.number(), // ← これは破壊的変更!
});

// 推奨: デフォルト値を使った段階的導入
const UserSchemaV2_GOOD = z.object({
  name: z.string(),
  email: z.string().email(),
  // デフォルト値を提供
  age: z.number().default(0),
});

今後の課題と発展的トピック

スキーマバージョニングをさらに進化させるための、今後のトピックを紹介します。

自動マイグレーション機能

typescript// 将来的な実装案: 自動マイグレーション検出
import { z } from 'zod';

function createAutoMigration<T, U>(
  fromSchema: z.ZodType<T>,
  toSchema: z.ZodType<U>
) {
  // スキーマ間の差分を検出
  const diff = detectSchemaDifference(fromSchema, toSchema);

  // 差分に基づいて自動的に変換関数を生成
  return generateConverter(diff);
}

// 使用例
const autoConverter = createAutoMigration(
  UserSchema.v1_0_0,
  UserSchema.v2_0_0
);

スキーマレジストリの構築

typescript// 将来的な実装案: スキーマレジストリ
class SchemaRegistry {
  private schemas = new Map<string, any>();

  register(
    name: string,
    version: string,
    schema: z.ZodType<any>
  ) {
    const key = `${name}@${version}`;
    this.schemas.set(key, schema);
  }

  get(
    name: string,
    version: string
  ): z.ZodType<any> | undefined {
    const key = `${name}@${version}`;
    return this.schemas.get(key);
  }

  listVersions(name: string): string[] {
    const versions: string[] = [];
    this.schemas.forEach((_, key) => {
      if (key.startsWith(`${name}@`)) {
        versions.push(key.split('@')[1]);
      }
    });
    return versions;
  }
}

// グローバルレジストリ
export const globalSchemaRegistry = new SchemaRegistry();

バージョニング運用の全体像を図でまとめます。

mermaidflowchart TD
  start[新しい要件] --> analysis{変更種別<br/>の判定}

  analysis -->|非破壊的| minor[MINORバージョン<br/>アップ]
  analysis -->|破壊的| major[MAJORバージョン<br/>アップ]

  minor --> addOptional[オプショナル<br/>フィールド追加]
  addOptional --> release1[リリース]

  major --> planning[移行計画策定]
  planning --> phase1[フェーズ1<br/>準備期間]
  phase1 --> phase2[フェーズ2<br/>移行期間]
  phase2 --> phase3[フェーズ3<br/>完了期間]
  phase3 --> release2[リリース]

  release1 --> monitor[モニタリング]
  release2 --> monitor

  monitor --> metrics{メトリクス<br/>確認}
  metrics -->|問題あり| rollback[ロールバック]
  metrics -->|問題なし| done[運用継続]

  style minor fill:#e8f5e8
  style major fill:#fff3e0
  style done fill:#e3f2fd
  style rollback fill:#ffebee

継続的な改善のために

スキーマバージョニング運用を成功させるには、継続的な改善が重要です。

メトリクスの収集

typescript// バージョン使用状況の追跡
class VersionMetrics {
  private metrics = new Map<string, number>();

  recordVersion(version: string) {
    const count = this.metrics.get(version) || 0;
    this.metrics.set(version, count + 1);
  }

  getDistribution() {
    const total = Array.from(this.metrics.values()).reduce(
      (sum, count) => sum + count,
      0
    );

    const distribution: Record<string, number> = {};
    this.metrics.forEach((count, version) => {
      distribution[version] = (count / total) * 100;
    });

    return distribution;
  }

  canDeprecateVersion(
    version: string,
    threshold = 5
  ): boolean {
    const distribution = this.getDistribution();
    return (distribution[version] || 0) < threshold;
  }
}

export const versionMetrics = new VersionMetrics();

ドキュメントの整備

バージョニング運用においては、以下のドキュメントを整備することが推奨されます。

  1. CHANGELOG.md - バージョンごとの変更履歴
  2. MIGRATION_GUIDE.md - バージョン移行ガイド
  3. API_VERSIONING.md - API バージョニングポリシー
  4. DEPRECATION_POLICY.md - 非推奨化ポリシー

これらを適切に運用することで、チーム全体でスキーマの進化を安全に管理できるようになります。Zod スキーマのバージョニングをマスターし、保守性の高いシステムを構築していきましょう。

関連リンク