ReactでJSONからTypeScriptの型を作って表示する方法

​​React​JSON,Playwright,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 などの既存のツールとの比較は試していません。

幅375pxの画面で撮ったコンポーネント。上に JSON の入力欄と「一番外側の型の名前」の入力欄(Cat)があり、その下の水色の枠に Cat、Owner、Address、ToysItem の4つの interface が並ぶ。右上のボタンは「コピーしました」になっている
猫のデータの JSON から4つの interface を作り、コピーしたところ

JSON の値から型を作る関数

型を作る処理は、inferTypes.ts にまとめました。jsonToTypes() に JSON.parse の結果と一番外側の型の名前を渡すと、型定義の文字列が返ります。

ts
// 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 のままです。

json
{
  "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
}
ts
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 の中身から型を決めます。

ts
// 元の 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 にかけた結果です。

text
$ 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 に入っていた値だけです。実際には日付の文字列が入ることもあるなら、見本を増やして配列にまとめて渡すと、型に反映されます。

json
[
  { "id": 1, "name": "たま", "birthday": null },
  { "id": 2, "name": "みけ", "birthday": "2020-04-01" }
]
ts
export type Root = RootItem[];

export interface RootItem {
  id: number;
  name: string;
  birthday: string | null;
}

birthday が string | null になりました。同じように、見本によって入ったり入らなかったりするキーも、見本を増やすと省略できるキーとして出てきます。

入力欄と型の表示を付ける

tsx
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>
  );
}
css
.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 では、次のメッセージが出ました。

text
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 の誤りを直して元の型に戻ると、ボタンはまた「コピーしました」になりました。

​Posted by 86