2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSON から TypeScript の型を作ったら、同じ users の要素が User と User2 に分かれた。配列の要素はまとめてから型にする

2
Posted at

背景

自分のサイトに、JSON を貼ると TypeScript の型定義を出すツールを置いています。
API のレスポンスを見ながら interface を手で書くのが面倒で(打ち間違えもよくやります…)、それでツールにしました。
先日、ユーザー一覧の JSON を貼ったら、User と User2 という2つの interface が出てきました。
中身を見比べると、違いはメールアドレスのキーが有るか無いかだけでした。
その備忘録です。

結論

配列の要素を1つずつ型にせず、配列の中のオブジェクトを先に1つにまとめてから型にします。
全部の要素にあるキーはそのまま、一部の要素にしか無いキーは ? を付けた optional にする。
これで (User | User2)[] ではなく User[] の1つになります。

interface は TypeScript で「オブジェクトがどのキーをどの型で持つか」を書く宣言で、email?: string の ? は「このキーは無くてもよい」という意味だ。

ツールの処理

ツールは、JSON.parse した値を typeOf に渡して、値の種類ごとに型の文字列を返しています。
オブジェクトは objectType で interface に切り出し、配列は arrayType で要素ごとに typeOf を呼んでいます。
hint は親のキー名で、users の要素なら singular('users') で User という名前になる。

function arrayType(arr, hint){
  if(arr.length === 0) return 'unknown[]';
  var member = hint ? singular(hint) : 'Item';
  var seen = [];
  for(var i = 0; i < arr.length; i++){
    var tt = typeOf(arr[i], member);
    if(seen.indexOf(tt) === -1) seen.push(tt);
  }
  if(seen.length === 1) return seen[0] + '[]';
  return '(' + seen.join(' | ') + ')[]';
}

objectType のほうは、同じ名前ですでに別の中身の interface があると、User2 User3 と連番を付けて分けています。
キーが1つ違うだけでも「別の中身」になるので、要素ごとに interface が増えていく仕組みでした。

実際に分かれた例

下記の JSON を、ツールの変換部分(DOM の操作を除いた関数)を写して Node.js v24.11.1 で変換しました。
2人目のユーザーには email がありません。

{"users":[{"id":1,"name":"Sato","email":"sato@example.com"},{"id":2,"name":"Suzuki"}]}

出力は下記です。

interface Root {
  users: (User | User2)[];
}

interface User2 {
  id: number;
  name: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

users が (User | User2)[] になっています。
この型だと、users[0].email と書いた時点で「User2 には email が無い」とエラーになるので、毎回どちらの型かを絞り込む必要が出ます。
API のレスポンスでは「値が無いときはキーごと省く」がよくあるので、実際の JSON を貼るとかなりの頻度でこうなる気がします。

直し方

配列の中のオブジェクトを、先に下記の mergeObjects で1つの見本にまとめます。
キーごとに何個の要素が持っていたかを数えて、要素の数より少ないキーを optional として返す関数だ。

function mergeObjects(objs){
  var sample = {}, count = {};
  objs.forEach(function(o){
    Object.keys(o).forEach(function(k){
      count[k] = (count[k] || 0) + 1;
      if(!(k in sample) || sample[k] === null) sample[k] = o[k];
    });
  });
  var optional = Object.keys(count).filter(function(k){ return count[k] < objs.length; });
  return { sample: sample, optional: optional };
}

見本の値は最初に見つかったものを使い、それが null なら後ろの要素の値で上書きしています(null だけだと型がわからないため)。

arrayType では、オブジェクトの要素をまとめて1回だけ objectType に渡し、文字列や数値などオブジェクト以外の要素は今までどおり1つずつ型にします。
objectType には3つ目の引数で optional を渡して、そこに入っているキーに ? を付けます(? を付ける条件に || optional.indexOf(k) !== -1 を足すだけ)。

function isPlainObject(x){ return x !== null && typeof x === 'object' && !Array.isArray(x); }

function arrayType(arr, hint){
  if(arr.length === 0) return 'unknown[]';
  var member = hint ? singular(hint) : 'Item';
  var seen = [];
  var rest = arr;
  var objs = arr.filter(isPlainObject);
  if(objs.length > 0){
    var m = mergeObjects(objs);
    seen.push(objectType(m.sample, member, m.optional));
    rest = arr.filter(function(x){ return !isPlainObject(x); });
  }
  for(var i = 0; i < rest.length; i++){
    var tt = typeOf(rest[i], member);
    if(seen.indexOf(tt) === -1) seen.push(tt);
  }
  if(seen.length === 1) return seen[0] + '[]';
  return '(' + seen.join(' | ') + ')[]';
}

同じ JSON を変換し直した結果が下記です。

interface Root {
  users: User[];
}

interface User {
  id: number;
  name: string;
  email?: string;
}

User が1つになり、email だけが ? 付きになりました。

なぜ union ではなく optional にするのか

(User | User2)[] も型としては正しく、情報は失っていません。
ただ、手元にある JSON は「たまたま貼ったサンプル」で、3人目は name が無いかもしれない。
サンプルの組み合わせごとに interface を増やすと、サンプルを変えるたびに型の数が変わってしまいます。
「このキーは無いことがある」と1つの型に書くほうが、API の実際の振る舞いに近いと思います。
その代わり、本当に別の種類のオブジェクトが混ざっている配列(type: "user" と type: "group" など)も1つにまとめてしまうので、そういう JSON では手で直す前提です。

作ったツールはここに置いています。
https://hashitosystem.com/tools/json2ts/

JSON から型を作るときは、配列の要素を1つずつ型にせず、まとめてから足りないキーを optional にします。


本記事はAI補助で執筆した、個人開発の紹介記事です。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?