3行まとめ
- APIレスポンスのJSONから、TypeScriptの
interface定義を自動生成するツールをぱんだツールズに作った - 配列内の要素でキーの有無が揺れているときは「全要素に存在するキーか」だけを見てoptionalを判定し、null混在は型へのunion追加という別の軸で扱う
- 配列内の全オブジェクトをまとめて1回だけ処理することで、同じ形のネストオブジェクトが重複してinterface化されるのを防いでいる
APIのレスポンスをそのままTypeScriptの型として使いたい場面は多い。だが手でinterfaceを書くのは地味に面倒で、サンプルJSONが複雑になるほどミスが増える。ぱんだツールズに「JSON→TypeScript型定義生成」ツールを作った。
この記事は、型推論の実装、特に「配列内の要素でキーが揺れているときにどうoptionalを判定するか」「null混在をどう型に落とすか」という設計判断を中心に書く。
何をするツールか
JSONテキストを貼ると、対応するTypeScriptのinterface定義を生成する。ネストしたオブジェクトはそれぞれ別のinterfaceに切り出され、配列は要素の型を推論して型名[]の形式で出力される。
{"user": {"id": 1, "name": "taro"}}
を入れると、こう出力される。
interface RootObject {
user: User
}
interface User {
id: number
name: string
}
ここまでは素直な再帰処理で書ける。厄介なのは「サンプルJSONが実データの一部でしかない」という前提にどう向き合うかだった。
設計判断1:配列内の要素はキーが揺れている前提で見る
実際のAPIレスポンスでは、配列の要素によってキーの有無が変わることがよくある。あるユーザーにはageがあるが、別のユーザーにはない、といったケースだ。
function collectPropertyKeys(objects: Record<string, JsonValue>[]): string[] {
const keys: string[] = []
const seen = new Set<string>()
for (const obj of objects) {
for (const key of Object.keys(obj)) {
if (!seen.has(key)) {
seen.add(key)
keys.push(key)
}
}
}
return keys
}
まず配列内の全要素からキーを収集する。1要素だけでなく全要素を見ることで、一部のオブジェクトにしか現れないキーも漏らさず拾う。
function inferPropertyInfo(
context: GenerationContext,
objects: Record<string, JsonValue>[],
key: string
): PropertyInfo {
const presentInAll = objects.every((obj) => Object.prototype.hasOwnProperty.call(obj, key))
// ...
return {
name: key,
type: /* ... */,
optional: !presentInAll,
}
}
presentInAllは「このキーが全要素に存在するか」を見るだけのシンプルな判定だが、これで「一部の要素にしかないキーは?付きのoptionalプロパティにする」が実現できる。
{"users": [{"id": 1, "name": "a"}, {"id": 2, "name": "b", "age": 30}]}
を入れるとidとnameは必須、ageは1要素にしかないのでoptionalになる。
interface RootObject {
users: User[]
}
interface User {
id: number
name: string
age?: number
}
キーの出現有無だけを見ているので、値そのものがnullになっているケースは別の軸として扱う必要がある。それが次の判断。
設計判断2:nullは「値の欠如」ではなく「型の一部」として扱う
キーが存在するかどうかの判定(hasOwnProperty)と、値がnullかどうかの判定は別物として扱っている。
const presentInAll = objects.every((obj) => Object.prototype.hasOwnProperty.call(obj, key))
const values = objects
.filter((obj) => Object.prototype.hasOwnProperty.call(obj, key))
.map((obj) => obj[key])
const hasNull = values.some((value) => value === null)
const nonNullValues = values.filter((value): value is Exclude<JsonValue, null> => value !== null)
const types = new Set<string>()
// ...nonNullValues から型を推論して types に追加...
if (hasNull) types.add('null')
{"nickname": null}のように常にnullなら型はnullそのものになる。配列内の一部要素だけnullで、他の要素では文字列が入っている場合は、nonNullValuesから推論した型(string)にnullを加えたstring | nullというユニオン型になる。同じtypesセットの仕組みは、数値と文字列が混在するような他の型のブレにも共通で効く。値の種類が複数集まれば、そのままユニオン型として出力される。
キーが存在しないケース(optional: true)と、キーは存在するが値がnullのケース(型にnullを含む)は、TypeScript的には意味が違う。前者は「アクセスする前に存在確認が必要」、後者は「アクセスはできるが値がないこともある」。この2つを混同せず別の軸として判定しているのが、この実装のポイントになる。
設計判断3:同じ形のオブジェクトはinterfaceを1個にまとめる
配列の要素がオブジェクトの場合、各要素を個別に処理すると同じ形のinterfaceが複数回生成されてしまう。そこで、配列内の全オブジェクトを1回だけまとめてinferObjectTypeに渡し、そこで初めてcollectPropertyKeysで全要素のキーを集約している。
function inferObjectType(
context: GenerationContext,
objects: Record<string, JsonValue>[],
nameHint: string
): string {
const name = reserveInterfaceName(context, nameHint)
const generated: GeneratedInterface = { name, properties: [] }
context.interfaces.push(generated)
generated.properties = collectPropertyKeys(objects).map((key) => inferPropertyInfo(context, objects, key))
return name
}
{"data": [{"id": 1, "user": {"name": "a"}}, {"id": 2, "user": {"name": "b"}}]}
このJSONはdata配列の2要素それぞれにuserオブジェクトがネストしている。愚直に実装するとUserinterfaceが2回生成されてしまうところだが、data配列を処理する時点で2要素分のuser値を集めてから1回だけinferObjectTypeを呼ぶ構造にしているので、Userは1個だけ生成される。
設計判断4:interface名の衝突とクォートが必要なキー名
ネストが深いJSONでは、違う階層に同じプロパティ名(userやitemなど)が複数回出てくることがある。生成するinterface名が衝突したときのために、連番サフィックスを振る仕組みを入れている。
function reserveInterfaceName(context: GenerationContext, nameHint: string): string {
const base = toPascalCase(nameHint) || 'Value'
if (!context.usedNames.has(base)) {
context.usedNames.add(base)
return base
}
let suffix = context.nameSuffixes.get(base) ?? 2
while (context.usedNames.has(`${base}${suffix}`)) suffix++
const uniqueName = `${base}${suffix}`
context.usedNames.add(uniqueName)
context.nameSuffixes.set(base, suffix + 1)
return uniqueName
}
1個目はUser、2個目以降はUser2、User3と振られる。名前の元ネタ(nameHint)は、オブジェクトのプロパティ名か、配列の場合は単数形化したプロパティ名(users→User)を使っている。
キー名そのものがTypeScriptの識別子として不正な場合(ハイフンを含む、数字から始まるなど)もある。
function formatPropertyName(key: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
}
識別子として有効な形式(英字・_・$始まり)でなければ、JSON.stringifyでクォート付きの文字列リテラルにする。"user-name"というキーなら"user-name": stringという形でそのまま出力される。interface名の方も数字始まりは許されないので、先頭にTを付けて回避している("2fa" → interface T2fa)。
テストケースで見る挙動
このロジックのテストには、設計判断がそのまま表れている。
it('配列内オブジェクトの差分キーをoptionalとして扱う', () => {
const result = convertJsonToTypeScript(
'{"users": [{"id": 1, "name": "a"}, {"id": 2, "name": "b", "age": 30}]}'
)
expect(result).toContain('age?: number')
})
it('配列要素内のネストしたオブジェクトプロパティを1つのinterfaceにマージする', () => {
const result = convertJsonToTypeScript(
'{"data": [{"id": 1, "user": {"name": "a"}}, {"id": 2, "user": {"name": "b"}}]}'
)
const userInterfaceCount = (result.match(/interface User\b/g) ?? []).length
expect(userInterfaceCount).toBe(1)
})
it('ルートがプリミティブ値の配列の場合は型エイリアスのみ生成する', () => {
const result = convertJsonToTypeScript('["a", "b", "c"]')
expect(result).toBe('type RootObject = string[]')
})
ルートが配列かオブジェクトかプリミティブかによって出力の形自体も変わる。オブジェクトの配列なら要素のinterfaceと型名[]の型エイリアス、プリミティブの配列なら型エイリアスだけ、という分岐もテストで固めている。
この実装が割り切っている部分
サンプルJSON1個(または1つの配列)から構造的に推論できることしかやらない。たとえば、見た目は違う名前のプロパティでも中身の形が同じなら1つのinterfaceに統合する、といった構造的な重複排除はしていない。名前が違えば別のinterfaceとして生成する。これは「生成されたコードがどのJSONのどの部分に対応しているか」を読みやすく保つためのトレードオフで、quicktypeのような多言語対応の大規模ツールとは設計の重心が違う。あくまで「ブラウザで1枚のJSONを貼ったら、すぐ使えるinterfaceが出てくる」という軽量さを優先した。
まとめ
- 配列内オブジェクトのoptional判定は「全要素にキーが存在するか」という
hasOwnPropertyベースの単純な判定で実現している - キーの非存在(optional)と値のnull(型への合併)は別の軸として扱い、混同しないようにしている
- 配列内の全要素をまとめて1回だけ処理することで、同じ形のネストオブジェクトが重複してinterface化されるのを防いでいる
- interface名の衝突は連番サフィックスで、識別子として不正なキー名はクォートで、それぞれ個別に吸収している
実データのJSONは手で書いたサンプルよりも形が揺れることが多い。「ありうる揺れ」を一つひとつ潰していく過程が、この手の変換ツールの実装の大半を占めている。
ぱんだツールズ では他にもPDF・画像・CSV・テキスト処理などの開発者向けツールを100個以上公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。