ReactでJSONからTypeScriptの型を作って表示する方法
JSON から TypeScript の型を作るには、JSON.parse で読み込んだ値を外側からたどります。オブジェクトは interface に、配列は要素の型に [] を付けた形に、文字列や数値は string や number に置き換えます。配列の中のオブジェクトどうしを比べると、一部にしかないキーは省略できるキー(?)に、値の種類が混ざる場所はユニオン型(string | number)にできます。
ここでは、型を作る関数 jsonToTypes() と、JSON を貼り付けると型が出てコピーできる JsonToTypes コンポーネントを、ライブラリを使わずに書きました。作った型に元の JSON を当てはめ、tsc でエラーにならないことも確かめています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。型を作る関数は Node.js で結果を確かめ、作った型は tsc にかけました。コンポーネントは tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを幅 375px の Chromium で Playwright から操作しました。Firefox と Safari、スマートフォンの実機、何 MB もある JSON や入れ子がとても深い JSON、quicktype などの既存のツールとの比較は試していません。

JSON の値から型を作る関数
型を作る処理は、inferTypes.ts にまとめました。jsonToTypes() に JSON.parse の結果と一番外側の型の名前を渡すと、型定義の文字列が返ります。
// JSON の値から、TypeScript の型定義の文字列を作る関数
type Primitive = 'string' | 'number' | 'boolean' | 'null';
const primitiveOrder: Primitive[] = ['string', 'number', 'boolean', 'null'];
// 1つの場所に現れた値の型を、すべてまとめたもの。種類が2つ以上あればユニオン型にする
type TypeInfo = {
primitives: Set<Primitive>;
object: ObjectInfo | null;
array: TypeInfo | null; // 配列の要素をまとめた型。空の配列だけなら、中身のない TypeInfo
};
type ObjectInfo = {
count: number; // まとめたオブジェクトの数
fields: Map<string, { type: TypeInfo; count: number }>; // count は、そのキーがあったオブジェクトの数
};
function emptyInfo(): TypeInfo {
return { primitives: new Set(), object: null, array: null };
}
// 値を調べて、into に型の情報を足していく。配列の要素は、すべて同じ TypeInfo にまとめる
function infer(value: unknown, into: TypeInfo = emptyInfo()): TypeInfo {
if (value === null) {
into.primitives.add('null');
} else if (Array.isArray(value)) {
into.array ??= emptyInfo();
for (const item of value) infer(item, into.array);
} else if (typeof value === 'object') {
into.object ??= { count: 0, fields: new Map() };
into.object.count++;
for (const [key, v] of Object.entries(value)) {
let field = into.object.fields.get(key);
if (!field) {
field = { type: emptyInfo(), count: 0 };
into.object.fields.set(key, field);
}
field.count++;
infer(v, field.type);
}
} else {
into.primitives.add(typeof value as Primitive);
}
return into;
}
// 識別子として書ける名前か(日本語などの文字も使える)
const identifier = /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u;
export function isIdentifier(name: string): boolean {
return identifier.test(name);
}
// 型の名前に使えるか。予約語(class など)と組み込みの型(string など)は小文字の英字で始まるので、
// 小文字の英字で始まる名前は受け付けない
export function isTypeName(name: string): boolean {
return isIdentifier(name) && !/^[a-z]/.test(name);
}
// キーの名前から型の名前を作る。user_id → UserId
function toTypeName(key: string): string {
const name = key
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join('');
if (name === '') return 'Item';
return isIdentifier(name) ? name : `T${name}`;
}
export function jsonToTypes(value: unknown, rootName = 'Root'): string {
const declarations: string[] = [];
const nameByBody = new Map<string, string>();
const usedNames = new Set<string>();
function reserveName(base: string): string {
let name = base;
for (let i = 2; usedNames.has(name); i++) name = `${base}${i}`;
usedNames.add(name);
return name;
}
function typeOf(info: TypeInfo, name: string, isRoot = false): string {
return unionParts(info, name, isRoot).join(' | ');
}
// ユニオン型の1つずつを返す。何も入っていなければ unknown。
// isRoot は一番外側のときだけ true にし、name をそのまま型の名前にする(最初に取ってあるため)
function unionParts(info: TypeInfo, name: string, isRoot: boolean): string[] {
const parts: string[] = [];
if (info.object) parts.push(interfaceFor(info.object, name, isRoot));
if (info.array) {
const item = unionParts(info.array, `${name}Item`, false);
// 要素がユニオン型なら、かっこで包んでから [] を付ける
parts.push(item.length > 1 ? `(${item.join(' | ')})[]` : `${item[0]}[]`);
}
parts.push(...primitiveOrder.filter((p) => info.primitives.has(p)));
return parts.length > 0 ? parts : ['unknown'];
}
function interfaceFor(obj: ObjectInfo, base: string, isRoot: boolean): string {
// 空のオブジェクトは、どんなキーでも受け付ける型にする
if (obj.fields.size === 0) return '{ [key: string]: unknown }';
// 親の名前と、書き出す位置を先に取ってから、子の型を作る
const name = isRoot ? base : reserveName(base);
const slot = declarations.push('') - 1;
const lines = [...obj.fields].map(([key, field]) => {
const prop = isIdentifier(key) ? key : JSON.stringify(key);
// すべてのオブジェクトにあったキーでなければ、省略できるキーにする
const optional = field.count < obj.count ? '?' : '';
return ` ${prop}${optional}: ${typeOf(field.type, toTypeName(key))};`;
});
const body = `{\n${lines.join('\n')}\n}`;
// 中身がまったく同じ型がすでにあれば、その名前を使い回す
const same = nameByBody.get(body);
if (same) {
usedNames.delete(name);
return same;
}
nameByBody.set(body, name);
declarations[slot] = `export interface ${name} ${body}`;
return name;
}
// 一番外側の型の名前は、子の型に使われないように最初に取っておく
usedNames.add(rootName);
const root = typeOf(infer(value), rootName, true);
if (root !== rootName) declarations.unshift(`export type ${rootName} = ${root};`);
return declarations.filter(Boolean).join('\n\n') + '\n';
}
上の画像で入力欄に入れている JSON(sample.json)を渡すと、次の型ができました。一番外側の型の名前は、既定の Root のままです。
{
"id": 1,
"name": "たま",
"birthday": null,
"tags": ["白", "長毛"],
"owner": { "name": "Sato", "address": { "city": "Tokyo", "zip": "100-0001" } },
"toys": [
{ "name": "ねずみ", "price": 300 },
{ "name": "ボール", "price": 120, "color": "red" }
],
"weights": [3.2, 3.4, null],
"scores": [1, "A"],
"notes": [],
"meta": {},
"user-agent": "example",
"体重": 4.1
}
export interface Root {
id: number;
name: string;
birthday: null;
tags: string[];
owner: Owner;
toys: ToysItem[];
weights: (number | null)[];
scores: (string | number)[];
notes: unknown[];
meta: { [key: string]: unknown };
"user-agent": string;
体重: number;
}
export interface Owner {
name: string;
address: Address;
}
export interface Address {
city: string;
zip: string;
}
export interface ToysItem {
name: string;
price: number;
color?: string;
}
同じ場所に出てきた値を、1つの型の情報にまとめる
処理は2段に分けています。まず infer() で JSON 全体をたどり、場所ごとに「どんな種類の値が出てきたか」を TypeInfo にまとめます。そのあと、TypeInfo を型定義の文字列に書き出します。
まとめ方の要は、配列の要素をすべて同じ TypeInfo に入れることです。toys の2つの要素は、どちらも1つの ObjectInfo に足されます。ObjectInfo は、まとめたオブジェクトの数と、キーごとに「そのキーがあったオブジェクトの数」を数えています。color は2つのうち1つにしかないので、数が合わず、color?: string になりました。
値の種類が混ざる場所は、出てきた種類を並べてユニオン型にします。[3.2, 3.4, null] は (number | null)[]、[1, "A"] は (string | number)[] です。ユニオン型は、並べた型のどれか1つに当てはまる値を表します(公式ハンドブックのユニオン型の説明)。要素の型がユニオン型のときは、かっこで包んでから [] を付けます。かっこがないと、number | null[] は「数値、または null だけの配列」という別の型になります。この型の変数に [3.2, null] を入れると、tsc は 3.2 の位置で Type 'number' is not assignable to type 'null'. というエラーを出しました。
中身のない値は、JSON から型を決めることができません。空の配列は unknown[] にしました。空のオブジェクトは、インデックスシグネチャを使って、どんなキーでも受け付ける { [key: string]: unknown } にしています。JSON に record というキーがあると、Record という名前の interface ができます。空のオブジェクトを Record<string, unknown> と書くと、組み込みの Record と名前が重なり、tsc が Type 'Record' is not generic. というエラーを出しました。そのため、この書き方にしています。いまの書き方なら、{"record": {"a": 1}, "meta": {}} から作った型を tsc にかけてもエラーは出ませんでした。
型に名前を付けて、別々の interface にする
入れ子のオブジェクトは、その場に { ... } と書くと読みにくくなります。そこで、キーの名前から型の名前を作り、別の interface として書き出しています。owner は Owner、user_id は UserId です。配列の要素のオブジェクトは、キーの名前に Item を付けて ToysItem にしました。toys を Toy のように単数形に直す処理は入れていません。
名前がぶつかったときは、あとから出てきたほうに番号を付けます。また、中身がまったく同じ型は、前に作った名前を使い回します。いろいろな JSON を渡して試した結果です。
| 渡した JSON | 出てきた型 |
|---|---|
{"user": {"name": "a", "user": {"id": 1}}} |
外側の user が User、内側の user が User2 |
{"home": {...}, "work": {...}}(中身は同じ形) |
home と work のどちらも Home |
[{"root": {"x": 1}}] |
type Root = RootItem[]、root のキーは Root2 |
[{"owner": null}, {"owner": {"name": "Sato"}}] |
owner: Owner | null |
{"matrix": [[1, 2], [3, 4]]} |
matrix: number[][] |
"hello" |
type Root = string |
外側の user が User になるように、親の名前は、子の型を作る前に reserveName() で取っています。一番外側の名前も最初に取っておくので、中に root というキーがあっても Root にはなりません。一番外側が配列や文字列のときは、interface ではなく type Root = RootItem[]; のような型の別名を作ります。
キーが識別子として書けなければ、引用符で囲む
user-agent のように、識別子として書けないキーは、JSON.stringify で引用符を付けて "user-agent": string; と書きます。{"1st": true, "user_id": 10, "$ref": "#/a", "": 0} では、1st と空文字列のキーだけが引用符付きになりました。
識別子かどうかは、Unicode の性質で文字を選ぶ正規表現の \p{ID_Start} と \p{ID_Continue} で判定しています。英字だけで判定すると、体重 のような日本語のキーまで引用符付きになるためです。体重: number; のまま tsc にかけても、エラーは出ませんでした。
作った型が元の JSON に合うかを tsc で確かめる
作った型が正しいかは、元の JSON をその型の変数に入れてみると分かります。jsonToTypes() の出力を types.ts に保存し、次の ok.ts から読み込みました。tsconfig で resolveJsonModule を有効にすると、.json を import でき、TypeScript が JSON の中身から型を決めます。
// 元の JSON(sample.json)を、作った型に当てはめる
import type { Root } from './types';
import sample from '../sample.json';
const data: Root = sample;
console.log(data.toys[1].color);
比べるために、型に合わない値を入れた bad.ts も用意しました。ほかは見本と同じ形で、birthday に '2020-04-01' を、toys の price に '300' という文字列を入れています。2つを tsc にかけた結果です。
$ npx tsc -p tscheck/tsconfig.json; echo exit=$?
tscheck/bad.ts(7,3): error TS2322: Type 'string' is not assignable to type 'null'.
tscheck/bad.ts(10,25): error TS2322: Type 'string' is not assignable to type 'number'.
exit=1
ok.ts ではエラーが出ず、bad.ts の2か所だけがエラーになりました。(7,3) は birthday の行、(10,25) は price のキーの位置です。合わないプロパティのエラーは、キーの位置に出ました。price のエラーからは、作った型が値の種類を正しく制限できていることが分かります。
一方の birthday は、少し事情が違います。見本の JSON では null しか入っていなかったので、型が null だけになりました。JSON 1つから分かるのは、その JSON に入っていた値だけです。実際には日付の文字列が入ることもあるなら、見本を増やして配列にまとめて渡すと、型に反映されます。
[
{ "id": 1, "name": "たま", "birthday": null },
{ "id": 2, "name": "みけ", "birthday": "2020-04-01" }
]
export type Root = RootItem[];
export interface RootItem {
id: number;
name: string;
birthday: string | null;
}
birthday が string | null になりました。同じように、見本によって入ったり入らなかったりするキーも、見本を増やすと省略できるキーとして出てきます。
入力欄と型の表示を付ける
import { useId, useState } from 'react';
import { isTypeName, jsonToTypes } from './inferTypes';
type Result = { types: string; error: '' } | { types: ''; error: string };
function convert(text: string, rootName: string): Result {
if (!isTypeName(rootName)) {
return { types: '', error: '型の名前は、大文字の英字や日本語などで始めてください' };
}
if (text.trim() === '') return { types: '', error: '' };
let value: unknown;
try {
value = JSON.parse(text);
} catch (e) {
return { types: '', error: `JSON として読めません:${(e as Error).message}` };
}
return { types: jsonToTypes(value, rootName), error: '' };
}
export function JsonToTypes({ initial = '' }: { initial?: string }) {
const id = useId();
const [text, setText] = useState(initial);
const [rootName, setRootName] = useState('Root');
// 最後にコピーした型の文字列
const [copied, setCopied] = useState('');
// 型は state に持たず、描くたびに作る
const { types, error } = convert(text, rootName);
async function copy() {
await navigator.clipboard.writeText(types);
setCopied(types);
}
return (
<div className="json-to-types">
<label htmlFor={`${id}-json`}>JSON</label>
<textarea
id={`${id}-json`}
rows={8}
spellCheck={false}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<label htmlFor={`${id}-name`}>一番外側の型の名前</label>
<input id={`${id}-name`} value={rootName} onChange={(e) => setRootName(e.target.value)} />
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<div className="types-header">
<span>TypeScript の型</span>
<button type="button" onClick={copy} disabled={types === ''}>
{types !== '' && copied === types ? 'コピーしました' : 'コピー'}
</button>
</div>
<pre className="types">
<code>{types}</code>
</pre>
</div>
);
}
.json-to-types {
display: grid;
gap: 6px;
max-width: 560px;
}
.json-to-types textarea,
.json-to-types input,
.json-to-types pre {
font-family: ui-monospace, monospace;
font-size: 14px;
}
.json-to-types .error {
margin: 0;
color: #d85878;
}
.types-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.types {
margin: 0;
padding: 8px 12px;
border: 1px solid #00c8e8;
border-radius: 6px;
background: #eefafd;
/* 長い行は、枠からはみ出さずに横にスクロールさせる */
overflow-x: auto;
}
state に持つのは、JSON の入力欄の文字列、型の名前、最後にコピーした型の文字列の3つです。型は state に持たず、描くたびに convert() で JSON から作ります。そのため、入力欄と型の表示がずれることはありません。
JSON が読めないときは、型の欄を空にする
JSON.parse は、JSON の文法に合わない文字列を受け取ると SyntaxError を投げます。convert() ではこれを受け止め、メッセージを出して、型の欄を空にします。見本の JSON の最後の } の前に , を足すと、コピーボタンは押せなくなりました。Chromium では、次のメッセージが出ました。
JSON として読めません:Expected double-quoted property name in JSON at position 382 (line 17 column 1)
入力欄を空にしたときは、メッセージは出さずに型の欄だけを空にしています。
型の名前は、小文字の英字で始まるものを受け付けない
一番外側の型の名前は、入力欄で変えられます。Cat にすると、1行目が export interface Cat { になりました。
ただし、識別子として書ける名前でも、型の名前に使えないものがあります。export interface class { a: number } を tsc にかけると Interface name cannot be 'class'. となり、string や unknown でも同じエラーが出ました。class のような予約語や、string のような基本の型の名前は、小文字の英字で始まります。そこで isTypeName() では、小文字の英字で始まる名前を受け付けないようにしました。猫 のような日本語の名前は、tsc でエラーになりませんでした。
型の名前を cat にすると、「型の名前は、大文字の英字や日本語などで始めてください」と出て、型の欄は空になりました。
「コピーしました」は、コピーした型と同じときだけ出す
コピーには navigator.clipboard.writeText() を使いました。MDN の英語版の writeText() のページによると、クリップボードへの書き込みは保護されたコンテキスト(セキュアコンテキスト)でしかできません。http://127.0.0.1 や http://localhost で配信したページは、HTTPS でなくても、潜在的に信頼できるものとして扱われます。検証では http://127.0.0.1:8080/ で開き、Playwright でクリップボードの権限を与えてから押しました。読み出したクリップボードの中身は、画面の型と同じでした。
ボタンの文言は、copied に入れた文字列が、いまの型と同じかどうかで決めています。コピーしたあとに JSON を書き換えると型が変わり、ボタンは「コピー」に戻ります。JSON の誤りを直して元の型に戻ると、ボタンはまた「コピーしました」になりました。





