スキーマと TypeScript 型の双方向同期
要点:型とスキーマはそれぞれ一つの区間を担当します。一方はコンパイル時、もう一方は実行時です。問いはどちらを選ぶかではなく、二つをどう自動で同期させるかです。

要点:まず真実の源を一つ決めましょう。バックエンドが契約を持つならスキーマ優先、フロントエンドが持つなら zod 形式の型優先です。次にもう一方を生成し、CI で差分を比較します。生成器で表現できない意味論(ブランド型など)だけを手書きします。
両方の定義を手で書けば、必ずずれます。ずれの代償は「コンパイルは通るのに実行時に 500 が返る」ことです。
1. 一つのコピーだけを保つとうまくいかない理由
| 能力 | TypeScript 型 | JSON Schema |
|---|---|---|
| コンパイル時チェック | ✓ | ✗ |
| 実行時検証 | ✗(型は消える) | ✓ |
| 言語をまたいだ利用 | ✗ | ✓(Java/Go/Python が読める) |
| IDE 補完とリファクタリング | ✓ | まあまあ |
| ドキュメント生成 | まあまあ | ✓(OpenAPI エコシステム) |
API の入力はネットワークから来て常に unknown なので、実行時検証は省けません。一方で業務コードを書くには型補完が必要です。
賢明な方法は、一方を真実の源として選び、もう一方を生成することです。
2. 経路 A:スキーマ優先
適する場面:バックエンド主導の作業、言語をまたぐ利用、公開契約。
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* JSON Schema から TypeScript の型定義を生成する。
* @param schemaPath スキーマファイルのパス
* @param outputPath 出力ファイルのパス
* @returns 生成完了時に解決する Promise
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* スキーマから自動生成。手で編集しないこと。 */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
出力の慣習はいくつか注目に値します。
required: ["id"]→id: string。requiredにないキー →id?: string。"type": ["string", "null"]→string | null。enum→ リテラル型のユニオン。stringよりはるかに精密。additionalProperties: falseがインデックスシグネチャを生成するかを決める。
ビルド不要の選択肢 json-schema-to-ts もあり、型レベルで直接推論します。
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
利点は生成ステップがなく型が常にスキーマと同期すること。欠点はスキーマを as const で書く必要があり、複雑な $ref はコンパイルが遅いことです。
3. 経路 B:型優先
適する場面:zod / valibot のようなライブラリを使うフロントエンド主導の作業で、「スキーマが型である」場合。
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** 他の言語に公開する契約 */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
ここでは zod が「型定義」と「実行時検証」の両方を担い、JSON Schema は単なるエクスポート成果物です。 全体として最も手間が少ない方法ですが、二点に注意してください。
z.inferと生成された JSON Schema はdefault、transform、refineで完全には対応しません。- 生成されたスキーマが
$refやdefinitionsを含む場合があるので、公開前に対象仕様に合うか確認してください。
4. 逆方向のチェックも必要
どちらの経路を選んでも CI チェックを追加します。生成物はリポジトリのファイルと一致しなければなりません。
# 一時ディレクトリに生成して比較し、差分があれば CI を失敗させる
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
このステップがなければ、三か月後に api.d.ts が最新かどうか誰も分かりません。
5. 頻出する四つの罠
5.1 optional と nullable は同じではない
type A = { name?: string }; // キーが存在しなくてもよい
type B = { name: string | null }; // キーは存在しなければならないが、値は null でもよい
スキーマで言うと:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
生成器は二つの場合をまったく別に扱います。契約でどちらを意味するかを明記しなければ、フロントエンドの data.name?.length が null でクラッシュします。
5.2 進化する enum
type Status = 'pending' | 'paid' | 'refunded';
バックエンドが 'cancelled' を追加し、フロントエンドの型が更新されていなければ、コードは default 分岐に落ちます。
enum 的なフィールドは未知の値を明示的に扱うべきです。
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 日付と金額は単純に string では済まない
スキーマでは format: "date-time" は注釈にすぎず、TypeScript ではただの string です。
より強い保証が必要なときは「ブランド型」を使います。
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* 文字列を ISO タイムスタンプとして印付ける。
* @param value 形式が検証済みの文字列
* @returns ブランド付きのタイムスタンプ文字列
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 大きな整数
type: "integer" は、値が 2^53 を超えると number と等価ではありません。契約にはこう書きます。
{ "type": "string", "pattern": "^[0-9]+$", "description": "注文番号。2^53 を超えるため文字列でなければならない" }
6. 推奨する導入プロセス
- 契約を定義する:まずリクエスト / レスポンスの構造をスキーマ(または zod)で書く。
- 型を生成する:CI で
.d.tsを生成し、リポジトリにコミットして差分をレビューしやすくする。 - バリデータを生成する:同じスキーマを Ajv の検証関数にコンパイルし、サーバーの各入口で呼ぶ。
- ドキュメントを生成する:スキーマから Markdown や OpenAPI を出力し、手書きで古びないようにする。
- 回帰:API のフィクスチャを「型チェック」と「スキーマ検証」の両方に通し、両方成功したものだけを合格とする。
7. チェックリスト
- [ ] 真実の源がちょうど一つあり、もう一方のコピーはスクリプトで生成されている。
- [ ] 生成スクリプトが CI で動き、成果物に差分があればビルドが失敗する。
- [ ] 契約で
optionalとnullableが明示的に区別されている。 - [ ] enum フィールドに未知値のフォールバックがある。
- [ ] 大きな整数、金額、時刻について、スキーマと型の両方に明示的な取り決めがある。
- [ ] 生成された型ファイルに「手で編集しないこと」のヘッダがある。
FAQ
スキーマ優先と型優先はどう決めればよいですか?
バックエンドが契約を持ち、言語をまたぐ利用や公開が必要ならスキーマ優先を選び、json-schema-to-typescript で型を生成します。フロントエンドが主導し、型と検証を一度に定義したいなら zod 形式の「スキーマが型」ライブラリを選び、JSON Schema をエクスポートします。どちらの経路でも、生成物とリポジトリのファイルを比較する CI ステップが必要です。
手書きの型とスキーマの定義はなぜ必ずずれるのですか?
型はコンパイル時に消え、スキーマは実行時にしか効かず、二つは異なる人が異なる時に編集します。生成と比較がなければ、一方が更新されている間にもう一方が止まり、「コンパイルは通るのに実行時に 500 が返る」状態を生みます。
生成した型ファイルをリポジトリにコミットすべきですか?
はい。コミットすればレビュー担当者が契約の変更を確認でき、CI が新しく生成した出力とリポジトリのファイルを比較して差分で失敗できます。成果物をローカルフォルダやビルドキャッシュにだけ置くと、三か月後に最新かどうか誰も分かりません。