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が正しい?
このようなチーム開発における課題として、以下が挙げられます。
- 変更の追跡が困難 - どの時点でどのフィールドが追加・削除されたか不明
- ブランチ間の不整合 - 異なるブランチで異なるスキーマバージョンが存在
- リリースタイミングの調整 - フロントとバックエンドのデプロイ順序に依存
課題
破壊的変更と非破壊的変更の見極め
スキーマの変更には、**破壊的変更(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 スキーマのバージョニング運用について学んできました。効果的なバージョニング運用のポイントをまとめましょう。
| ポイント | 内容 | メリット |
|---|---|---|
| 1 | SemVer の適用 | 変更の影響範囲が明確になる |
| 2 | 互換レイヤーの実装 | 段階的な移行が可能になる |
| 3 | バージョンマネージャーの導入 | 変換処理を一元管理できる |
| 4 | 3 段階移行モデル | リスクを最小化できる |
| 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();
ドキュメントの整備
バージョニング運用においては、以下のドキュメントを整備することが推奨されます。
- CHANGELOG.md - バージョンごとの変更履歴
- MIGRATION_GUIDE.md - バージョン移行ガイド
- API_VERSIONING.md - API バージョニングポリシー
- DEPRECATION_POLICY.md - 非推奨化ポリシー
これらを適切に運用することで、チーム全体でスキーマの進化を安全に管理できるようになります。Zod スキーマのバージョニングをマスターし、保守性の高いシステムを構築していきましょう。
関連リンク
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleDeno/Bun/Node のランタイムで共通動く Zod 環境のセットアップ
articleZod で“境界”を守る設計思想:IO バリデーションと型推論の二刀流
articleZod スキーマのバージョニング運用:SemVer・互換レイヤー・段階移行の実践
articleZod で「never に推論される」問題の原因と対処:`narrowing` と `as const`
articleZod vs Ajv/Joi/Valibot/Superstruct:DX・速度・サイズを本気でベンチ比較
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleバックアップ戦略の決定版:WordPress の世代管理/災害復旧の型
articleYarn 運用ベストプラクティス:lockfile 厳格化・frozen-lockfile・Bot 更新方針
articleWebSocket のペイロード比較:JSON・MessagePack・Protobuf の速度とコスト検証
articleWeb Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集
articleWebRTC SDP 用語チートシート:m=・a=・bundle・rtcp-mux を 10 分で総復習
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来