1
1

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 の interface が返ってくるツールを 500 行で作った

1
Posted at

きっかけ

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 TypeScripthttps://sen.ltd/portfolio/json-to-ts/

スクリーンショット

左に JSON、右に TypeScript。それだけ。でも quicktype にない機能を 3 つ足しました:

  1. 複数サンプルマージ — 異なる API レスポンス 2〜3 件を貼るとマージして optional フィールドを検出
  2. Type guard 関数の自動生成isUser(obj): obj is User をセットで出力
  3. 日本語キー対応{ "名前": "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.js3 ファイルが本体。合計 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 ゼロ依存でどこまで行けるかを試しています。

バグ報告・改善案、nasty な JSON で壊れた話、歓迎です。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?