JSON から TypeScript へ:インターフェース、型ガード、生成の限界
要点:サンプル JSON からインターフェースを生成するのは仕事の半分です。任意フィールド、null、ユニオン、日付、大きな整数には人の手が必要で、型は実行時検証をしてくれません。

要点:タイピングを省くためにインターフェースを生成し、その後で任意性と null を再確認しましょう。日付と大きな整数は文字列のまま保ち、境界に実行時検証を加えます。生成された型を契約とみなすと、検証はまったく得られません。
生成器はあなたが貼り付けた 1 つのサンプルしか見ません。フィールドが欠けうるか、値が null になりうるか、日付がどの形式かはすべて推測であり、誤った推測を直すのはあなたの仕事です。
1. 生成器が実際にしていること
JSON サンプルを JSON to TypeScriptに貼ると、返ってくるインターフェースは形の要約です。見たキーと値の型を数え、利用可能な最も狭い表現を選びます。
{
"id": 1,
"email": "ada@example.com",
"profile": { "nickname": "Ada", "age": 36 },
"tags": ["admin", "beta"],
"createdAt": "2026-10-31T08:00:00Z"
}
出力はおおよそこうなります。
interface User {
id: number;
email: string;
profile: { nickname: string; age: number };
tags: string[];
createdAt: string;
}
コンパイルは通りますが、これは理想の値を記述しており、エンドポイントの実際の契約ではありません。生成器は email が欠けうるか、tags が空になりうるか、createdAt が常にタイムゾーンを持つかを知りません。他のレコードで id が文字列かもしれないことすら知りません。
ですから正しい期待はこうです。生成器はタイピングを省き、人間が意味を供給する。
2. 任意フィールドと null:? は | null ではない
ここが生成出力で最も頻繁にコピーされ、最も頻繁に壊れる部分です。
| TypeScript の形 | 意味 | 対応する JSON |
|---|---|---|
email: string |
常に存在し、常に文字列 | {"email":"a@b.c"} |
email?: string |
キーは欠けうる。存在するとき null にはならない | {} または {"email":"a@b.c"} |
email: string | null |
キーは常に存在し、値は null になりうる | {"email":null} |
email?: string | null |
キーは欠けうるし、値も null になりうる | 三つの場合すべて |
生成器は 1 サンプルしか見ていないので、通常は最初の形を出力します。API が実際に null を返しうるなら、型は嘘をついています。すべてコンパイルが通った後、実行時に email.toLowerCase() が例外を投げます。
修正方法は二つ:
- 「キー欠落」と「値が null」の両方をカバーする実サンプルを複数集め、生成器にもう一度推論させる。
- 生成後にフィールドを手で
?: T | nullに変え、コードで両方の分岐を実際に扱う。
すべてを ?: string にする近道は取らないでください。任意は「キーが存在しないことがある」であって「値が空でありうる」ではありません。undefined と null は JSON.stringify でも異なる振る舞いをします。前者は省略され、後者は null として残ります。
3. 不揃いな配列要素とユニオン型
配列は最も間違えやすい構造です。tags が三つのレコードで ["admin"]、[]、["beta", 3] と現れるなら、生成器は見たサンプルに応じて string[]、(string | number)[]、unknown[] のいずれかを出します。
人間が決めなければなりません。
- 要素の型が本当に混在している:
(string | number)[]と書き、使用箇所で絞り込む。 - 汚れているレコードが一つだけ:データを直す。
any[]で隠さない。 - 空配列:生成器は要素型を推論できず
never[]やany[]に落ちるので、自分で埋める。 - 固定長タプル:業務が本当に長さを保証するときだけ
[string, string]を使う。さもないと後で要素を足すのがコンパイルエラーになります。
キーが不揃いなオブジェクト配列にも注意が必要です。
interface OrderItem {
sku: string;
quantity: number;
discount?: number; // 一部のレコードにはこのフィールドがない
}
任意性は配列ではなく要素型に置きます。items?: OrderItem[] は配列自体が欠けうるという意味で、まったく別の主張です。
4. 日付、大きな整数、enum:JSON にない型
JSON には文字列と数値しかなく、業務上の意味は慣習に住んでいます。生成器はリテラルから推論するので、手で直します。
| JSON リテラル | 通常の生成出力 | あるべきもの |
|---|---|---|
"2026-10-31T08:00:00Z" |
string |
輸送層では string のまま保ち、使用箇所で変換する。Date を直接宣言しない |
1717075200000 |
number |
ミリ秒タイムスタンプには number を使い、単位を文書化する |
"1234567890123456789" |
string |
string のまま保ち、number が精度を失わないようにする |
1 と "1" の混在 |
number | string |
一つの形に正規化する。ユニオンを業務コードに押し込まない |
"admin" |
string |
リテラルユニオン 'admin' | 'editor'、または共有の enum 定数 |
true と "true" の混在 |
boolean | string |
この型を生成するのではなく、データ源を直す |
最も重要な規則:JSON の日付は常に文字列です。createdAt: Date と書くのはよくある自己欺瞞です。JSON.parse は決して Date を構築しないので、実行時の値は文字列のままで .getTime() が例外を投げます。輸送型は string に保ち、境界で明示的に new Date(value) を呼び、Number.isNaN を確認してください。
大きな整数も同じです。id が 2^53 を超えると、JSON.parse はすでに精度を失っており、number を生成するとバグを型に固定するだけです。バックエンドが文字列を返し、フロントエンドが文字列のまま保つ。それが唯一信頼できる選択です。
5. 生成された型は実行時検証をしない
TypeScript の型はコンパイル時に消えます。インターフェースがどれほど精密でも、{"id": "x"} というレスポンスを止められません。それは自分のコードを制約するのであり、ネットワークを流れるバイトではありません。
/**
* 実行時の値がユーザーオブジェクトか確認する。
* @param value 検証する実行時の値
* @returns User の最小の形を満たすか
*/
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}
型ガードはチェックに書いたフィールドだけをカバーします。フィールドが増えると、手書きのガードは第二の定義になり、インターフェースからずれていきます。
実践的なトレードオフはこうです。
| 方法 | 実行時検証 | 型の源 | 適する場面 |
|---|---|---|---|
| インターフェースのみ | なし | 手書きか生成 | 信頼できる内部データ、コンパイル時の制約 |
| インターフェース+手書きガード | あり | ずれる二つの定義 | フィールドが少なく、境界が明確 |
| zod / valibot | あり | スキーマから推論した型 | 外部 API、フォーム、設定 |
| JSON Schema+バリデータ | あり | スキーマから生成した型 | 言語をまたぐ契約、ドキュメント |
データが外部から来るなら、スキーマを単一の真実の源にし、z.infer や json-schema-to-ts で型を導出します。そうすれば「検証が通る」と「型が正しい」が同じ事実になり、ずれる第二の定義がありません。双方向同期の完全な方法はスキーマと TypeScript 型の同期を参照してください。
6. interface か type か:生成出力で議論しない
生成器は通常 interface を出しますが、ほとんどの場面で二つは交換可能です。実際に重要なのは三つの能力です。
- 宣言マージ:
interfaceは複数回宣言でき自動でマージされますが、typeはできません。サードパーティ型のパッチに役立ちますが、業務モデルでは避けるべきです。マージは「余分なフィールド」をコンパイラに気づかれず通してしまいます。 - ユニオンとマップ型:
typeはA | B、Partial<T>、テンプレートリテラル型を直接表現できますが、interfaceはできません。 - 再帰:どちらも対応しますが、複雑な再帰形状は
typeの方が自然に読めます。
より有用なチーム規則:オブジェクト形状には interface、ユニオン、ユーティリティ型、関数型には type。生成出力は既定のままにし、レビュー時間は任意性と実行時検証に使ってください。
7. ツールチェーン:三つの生成方法
| 方法 | 入力 | 強み | 制限 |
|---|---|---|---|
| このサイトの JSON to TypeScript | サンプル JSON | すぐ使える。ローカル処理 | 現在のサンプルのみ。手動の絞り込みが必要 |
| quicktype | JSON / JSON Schema | 多言語出力、CLI 統合 | 保守的な推論。ユニオンが広すぎることがある |
| json-schema-to-typescript | JSON Schema | 契約駆動で再現可能 | 信頼できるスキーマが先に必要 |
典型的な quicktype の呼び出し:
npx quicktype -s json -o user.ts user.json --just-types
--just-types は型だけを出し、実行時の変換コードを出しません。すでにパース層を持つプロジェクトに適します。スキーマが手書きかバックエンド製なら、json-schema-to-typescript の方が適します。required、enum、additionalProperties を尊重するからです。サンプル推論には見えない制約です。
スキーマ生成にも罠があり、すべてのフィールドを必須にしたり、null を静かに任意に変えたりします。JSON Schema 生成器のよくある落とし穴を参照してください。
8. 生成型をコミットする前のチェックリスト
出力をコミットする前に次の五項目を通します。
- フィールド名を正規化する:
userIdとuser_idを共存させない。変換層で一度マップする。 - 任意性を再確認する:実際のレスポンスと比較し、どのフィールドが欠けうるか、
nullになりうるかを見つける。 - 日付と id を文字列のまま保つ:型に単位と形式を文書化し、使用箇所で明示的にパースする。
anyとunknownを減らす:生成器がanyを出した箇所は、型を足すかunknownのままガードを足す。- 実行時検証を加える:外部データは少なくとも一度検証する。インターフェースが不正データを捕まえると期待しない。
プロジェクトが複数の JSON ライブラリ(JSON.parse、superjson、lossless-json)を使うなら、大きな整数と日付の振る舞いが異なるので、選択時に確認してください。JSON パースライブラリの選択を参照してください。
生成器がすべての任意フィールドを必須にするのはなぜですか?
1 サンプルしか見ていないので、現れたすべてのキーを常に存在すると扱います。異なるケースをカバーする複数サンプルを渡すか、JSON Schema から生成してください。そこの required 配列が権威ある源です。
インターフェースで Date を宣言すると実行時に失敗するのはなぜですか?
JSON.parse は文字列、数値、真偽値、null、オブジェクト、配列しか生成せず、Date を決して構築しません。Date の宣言はコンパイル時の約束で、実行時の値は文字列のままです。輸送層では string を保ち、境界で変換してください。
生成された型を検証に使えますか?
いいえ。TypeScript の型はコンパイル後に消え、実行時データに何も課しません。外部データは zod、valibot、ajv、または手書きの型ガードで検証してください。
大きな整数の id は number と string のどちらにすべきですか?
2^53 を超えうるなら string でなければなりません。JSON.parse はパース中に精度を失い、number と型付けするとバグを型に書き込み、もう直せなくなります。