背景
自分のサイトに、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補助で執筆した、個人開発の紹介記事です。