きっかけ
API のレスポンスから TypeScript の interface を書く作業、毎回地味に面倒じゃないですか。
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"address": { "city": "Tokyo", "zip": "100-0001" },
"tags": ["admin", "beta"]
}
これを見ながら
interface User {
id: number
name: string
email: string
address: Address
tags: string[]
}
interface Address {
city: string
zip: string
}
と書く、というだけの作業。quicktype を使ってもいいんだけど、多言語対応の大きなツール で、ブラウザ版も若干重くて、欲しいのはもっとシンプルなやつ。
というわけで、300 行くらいの vanilla JS で作りました。
作ったもの
JSON to TypeScript — https://sen.ltd/portfolio/json-to-ts/
左に JSON、右に TypeScript。それだけ。でも quicktype にない機能を 3 つ足しました:
- 複数サンプルマージ — 異なる API レスポンス 2〜3 件を貼るとマージして optional フィールドを検出
-
Type guard 関数の自動生成 —
isUser(obj): obj is Userをセットで出力 -
日本語キー対応 —
{ "名前": "Alice" }でも壊れず、ちゃんと JSDoc コメントとして原文を残す
全部 vanilla JS + HTML + CSS、ビルドツールなし、ゼロ依存。総行数は約 500 行(テスト含む)。
どう作ったか
構成
src/
├── parser.js # JSON 文字列 → 値(エラーハンドリング)
├── infer.js # 値 → TypeScript AST
├── generator.js # AST → interface 文字列
├── guard.js # AST → 型ガード関数文字列
└── main.js # DOM 配線
infer.js generator.js guard.js の 3 ファイルが本体。合計 300 行ちょっと。
型推論のコア
JSON の値を受け取って、TypeScript の型 AST を返す再帰関数。
function toTsType(value, name, ctx) {
if (value === null) return { kind: 'primitive', name: 'null' }
const t = typeof value
if (t === 'string' || t === 'number' || t === 'boolean') {
return { kind: 'primitive', name: t }
}
if (Array.isArray(value)) {
let element = toTsType(value[0], singularize(name), ctx)
for (let i = 1; i < value.length; i++) {
element = mergeTypes(element, toTsType(value[i], singularize(name), ctx), ctx)
}
return { kind: 'array', element }
}
if (t === 'object') {
// 新しい interface を作って参照を返す
const iface = { name: pascalCase(name), fields: [] }
ctx.interfaces.push(iface)
for (const key of Object.keys(value)) {
iface.fields.push({
key,
type: toTsType(value[key], key, ctx),
optional: false,
})
}
return { kind: 'object', ref: iface.name }
}
}
配列要素の名前は「users → User」のように単数形化します。英語の単数化は限定的ですが、簡易なルールでも 8 割カバーできる:
function singularize(name) {
if (/ies$/.test(name)) return name.replace(/ies$/, 'y')
if (/ses$/.test(name)) return name.replace(/es$/, '')
if (/s$/.test(name) && !/ss$/.test(name)) return name.replace(/s$/, '')
return name
}
複数サンプルマージの肝
2 つの型をマージする関数を作ると、ほとんどの「複数 JSON を合わせて 1 つの interface」問題が自動で解決します。
function mergeTypes(a, b, ctx) {
if (typesEqual(a, b)) return a
// 両方プリミティブで違う → union
if (a.kind === 'primitive' && b.kind === 'primitive') {
return { kind: 'union', types: [a, b] }
}
// 両方 array → element を再帰マージ
if (a.kind === 'array' && b.kind === 'array') {
return { kind: 'array', element: mergeTypes(a.element, b.element, ctx) }
}
// 両方 object → interface レベルでマージ
if (a.kind === 'object' && b.kind === 'object') {
mergeInterfacesInPlace(a.ref, b.ref, ctx) // 片方に寄せる
return a
}
// それ以外 → union
return { kind: 'union', types: [a, b] }
}
interface レベルのマージでは「片方にしかない key は optional にする」ルール:
function mergeInterfacesInPlace(aName, bName, ctx) {
const a = ctx.interfaces.find(i => i.name === aName)
const b = ctx.interfaces.find(i => i.name === bName)
const allKeys = new Set([...a.fields.map(f => f.key), ...b.fields.map(f => f.key)])
const merged = []
for (const key of allKeys) {
const fa = a.fields.find(f => f.key === key)
const fb = b.fields.find(f => f.key === key)
if (fa && fb) {
merged.push({ key, type: mergeTypes(fa.type, fb.type, ctx), optional: fa.optional || fb.optional })
} else {
merged.push({ ...(fa || fb), optional: true })
}
}
a.fields = merged
ctx.interfaces.splice(ctx.interfaces.indexOf(b), 1)
}
これで 2 つの API レスポンスを貼ると:
{"id": 1, "name": "A", "email": "a@x"}
{"id": 2, "name": "B", "age": 30}
こう推論される:
export interface Root {
id: number
name: string
email?: string
age?: number
}
Type guard 関数の生成
推論した AST から、obj is T を返す type guard を生成します。各フィールドに対して適切な typeof チェックを再帰的に組み立てる。
function renderTypeCheck(type, expr) {
if (type.kind === 'primitive') {
if (['string', 'number', 'boolean'].includes(type.name)) {
return `typeof ${expr} === '${type.name}'`
}
if (type.name === 'null') return `${expr} === null`
}
if (type.kind === 'array') {
const inner = renderTypeCheck(type.element, '__e')
return `Array.isArray(${expr}) && (${expr} as unknown[]).every((__e) => ${inner})`
}
if (type.kind === 'object') {
return `is${type.ref}(${expr})` // 兄弟 guard を呼び出す
}
if (type.kind === 'union') {
return type.types.map(t => `(${renderTypeCheck(t, expr)})`).join(' || ')
}
}
optional フィールドは undefined チェックを先に:
if (o.email !== undefined && !(typeof o.email === 'string')) return false
これだけで、実行時にも型を信じられるようになるのが地味に便利。API のレスポンスを盲目的に as User するより明らかに安全。
日本語キー対応
地味ですが、日本語の API もあります。クオートの有無だけで壊れるツールも多いので、そこもちゃんと動くようにしました:
Input:
{"名前": "Alice", "年齢": 30}
Output:
export interface Root {
/** 名前 */
"名前": string
/** 年齢 */
"年齢": number
}
interface のプロパティ名として有効な識別子でない key は自動でクオート、さらに JSDoc コメントとして残します。type guard も以下のように生成:
if (typeof o["名前"] !== 'string') return false
テスト
node --test で 44 ケース。外部ライブラリゼロ。
npm test
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 2 件目です。前作の Cron TZ Viewer と同じく、vanilla JS ゼロ依存でどこまで行けるかを試しています。
- 📦 レポジトリ: https://github.com/sen-ltd/json-to-ts
- 🌐 ライブデモ: https://sen.ltd/portfolio/json-to-ts/
- 🏢 会社: https://sen.ltd/
バグ報告・改善案、nasty な JSON で壊れた話、歓迎です。
