🐼

TypeScript Compiler API で型を自動生成する仕組みを TDD で実装する

2021/09/16に公開

Compiler API で yaml から型を自動生成する

この記事は TypeScript Compiler API を実際に使って TypeScript の型を自動生成する方法を紹介しています。

記事内では Google Analytics のイベントの型の自動生成にトライしています。

題材として GA を選んだのは、以前 Next.js に Google Analytics(GA) を導入する方法を紹介した記事でGA のイベントで使う型を yaml から自動生成するのはどうか と書いていたからです。

正直なところ、GA のイベントに馴染みのない方もいらっしゃると思うので、最初はもっと一般的な例にしようかなとも考えました。

しかし、一般的なことを例に挙げると「ふーん、便利だね」という感想で終わってしまい、業務で自分が使うようなイメージが湧かないかもしれないと考えました。

そこで、あえて少し説明が必要でも実際に実務で使えそうな例として GA を採用しています。

さて、TypeScript Compiler API を使って実際に作ってみましょう。 せっかくなので TDD で開発してみます。TDD はテスト駆動開発(Test Driven Development)の略で、テスト結果と対話しながらプログラムを開発する開発技法です。

なお、この記事では 「TypeScript Compiler API を実務で使えそうなものとして捉える」ことに焦点を当てています。

このため、包括的な説明はドキュメントを読んだり、他のブログ記事を読んでみてください。日本語の情報であれば @takepepe さんのTypeScript CompilerAPI - 創出の落書帳 -という書籍が詳しいです。

パッケージのインストール

まずは TS 関連のパッケージをインストールします。

$ yarn add -D @types/node ts-node tsconfig-paths typescript

次に jest 関連のパッケージをインストールします。

$ yarn add -D jest @types/jest ts-jest

今回の主なファイルは以下の通りです。

.
├── index.ts       # yaml を読み取り、types.ts を生成する
└── index.test.ts  # テスト
├── events.yaml    # 型を設定する yaml
└── types.ts       # 型の出力先

jest の設定ファイルなどの他のファイルも存在しますが、ここでは省略しています。今回作成したコードはGitHub で公開しています。

目標を確認する

この記事では Google Analytics(以下、GA) のイベントで使う型を作成するのでした。まずはゴールを確認しましょう。

Google Analytics をフロントで使う際、自分は GA イベントの型と関数を以下のように定義しています。

// gtag.ts
type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}

type ConvertEnd = {
  action: 'convert_end'
  category: 'convert'
}

type ConvertError = {
  action: 'convert_error'
  category: 'convert'
}

type Event = (ConvertStart | ConvertEnd | ConvertError) & {
  label?: Record<string, string | number | boolean>
  value?: string
}

export const event = ({ action, category, label, value = '' }: Event) => {
  window.gtag('event', action, {
    event_category: category,
    event_label: label ? JSON.stringify(label) : '',
    value,
  })
}

例えば、クリックイベントを取得するときは以下のように活用します。

<Button onClick={() => {
  event({
    action: 'convert_start',
    category: 'convert',
    label: { position: 'main' },
  })
}}>コンバートする</Button>

event 関数の引数を Event 型で定義しているので、タイポをして集計結果がおかしくなったりプロパティの設定漏れを防げます。

しかし、今は event 関数は関係ありませんね。今回は型を yaml から作成することが目標です。そこで、型ファイルを分離し、以下のようなtypes.tsがコマンドで生成できるようになれば完成としましょう。

// types.ts
type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}

type ConvertEnd = {
  action: 'convert_end'
  category: 'convert'
}

type ConvertError = {
  action: 'convert_error'
  category: 'convert'
}

type Event = (ConvertStart | ConvertEnd | ConvertError) & {
  label?: Record<string, string | number | boolean>
  value?: string
}

実際に使う際は Event 型をgtag.tsからインポートします。

// gtag.ts
import { Event } from './types'

export const event = ({ action, category, label, value = '' }: Event) => {
  window.gtag('event', action, {
    event_category: category,
    event_label: label ? JSON.stringify(label) : '',
    value,
  })
}

これで目標がどのようなものかわかりました。

簡単なテストを作成する

実装に入る前に、まず Jest でテストを実行できることを確認してみます。Jest - TypeScript Deep Dive 日本語版 を参考に Jest の設定ファイルjest.config.jsを作成し、サンプルコードを利用して簡単なテストを実行してみます。

// index.ts
export const sum  = (...a: number[]) => a.reduce((acc, val) => acc + val, 0);
// test.ts
import { sum } from './index'

test('basic', () => {
  expect(sum()).toBe(0);
});

test('basic again', () => {
  expect(sum(1, 2)).toBe(3);
});

$ yarn testでテストを実行します。

$ yarn test
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts
  ✓ basic (3 ms)
  ✓ basic again

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.011 s
Ran all test suites.
✨  Done in 5.60s.

テストが実行できることを確認できました。次のステップに進みましょう。

index.ts を実装して、型作成のための値から AST を作成する

index.ts を実行して types.ts を自動生成するようにしましょう。処理の流れは以下の通りです。

  • yaml ファイルを読み込み、パースして配列にする
  • 配列から型作成に必要な値を抜き出す
  • 型作成のための値から AST を作成する
  • AST を文字列に変換する
  • 文字列を ts ファイルに出力する

このプログラムの核となる型作成のための値から AST を作成するという処理から取り掛かりましょう。 なお、AST (抽象構文木)はプログラムを木構造で表現したものです。

型作成のための値から AST を作成する

落ちるテストを書く

ここからはテストファイルと実装ファイルを行き来します。

まずはテストを作成します。しかし、AST を生成するテストはどのように書けばいいでしょうか。

ここで TypeScript AST Viewer にアクセスし、以下のコードを左上の入力欄に貼り付けます。

type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}

すると、左下にこのコードを生成する factory 関数が自動生成されます。

TypeScript AST Viewer の画面

この factory 関数を使ってテストを記述しましょう。TDD ではテストから記述していきます。

少し見た目に難がありますが、ここはあえて簡略化などせずそのまま使います。

// index.test.ts
import { factory } from 'typescript'

import { createTypeAlias } from './'

describe('createTypeAlias で型作成のための値から type alias を作成する', () => {
  test('型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する', () => {
    const actual = createTypeAlias()
    /**
     * type ConvertStart = {
     *   action: 'convert_start'
     *   category: 'convert'
     * }
     */
    const expected = factory.createTypeAliasDeclaration(
      undefined,
      undefined,
      factory.createIdentifier("ConvertStart"),
      undefined,
      factory.createTypeLiteralNode([
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("action"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert_start"))
        ),
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("category"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
        )
      ])
    )

    expect(actual).toBe(expected)
  })
})

まだindex.tscreateTypeAliasを記述していないので作成します。

// index.ts
export const createTypeAlias = () => {

}

この状態でテストを実行してみましょう。

関数に何も実装していないのでテストは落ちますが、まずは落ちるテストを書くのが TDD の最初の一歩です。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✕ 型名 ConvertStart 、プロパティ、値が与えられたとき、ConvertStart 型の AST を作成する (8 ms)

  ● createTypeAlias で型作成のための値から type alias を作成する › 型名 ConvertStart 、プロパティ、値が与えられたとき、ConvertStart 型の AST を作成する

    expect(received).toBe(expected) // Object.is equality

    Expected: {"decorators": undefined, "end": -1, "flags": 8, "kind": 257, "localSymbol": undefined, "locals": undefined, "modifierFlagsCache": 0, "modifiers": undefined, "name": {"end": -1, "escapedText": "ConvertStart", "flags": 8, "kind": 79, "modifierFlagsCache": 0, "originalKeywordKind": undefined, "parent": undefined, "pos": -1, "transformFlags": 0}, "nextContainer": undefined, "parent": undefined, "pos": -1, "symbol": undefined, "transformFlags": 1, "type": {"end": -1, "flags": 8, "kind": 180, "members": [{"decorators": undefined, "end": -1, "flags": 8, "kind": 164, "localSymbol": undefined, "locals": undefined, "modifierFlagsCache": 0, "modifiers": undefined, "name": {"end": -1, "escapedText": "action", "flags": 8, "kind": 79, "modifierFlagsCache": 0, "originalKeywordKind": undefined, "parent": undefined, "pos": -1, "transformFlags": 0}, "nextContainer": undefined, "parent": undefined, "pos": -1, "questionToken": undefined, "symbol": undefined, "transformFlags": 1, "type": {"end": -1, "flags": 8, "kind": 194, "literal": {"end": -1, "flags": 8, "hasExtendedUnicodeEscape": undefined, "kind": 10, "modifierFlagsCache": 0, "parent": undefined, "pos": -1, "singleQuote": undefined, "text": "convert_start", "transformFlags": 0}, "modifierFlagsCache": 0, "parent": undefined, "pos": -1, "transformFlags": 1}}, {"decorators": undefined, "end": -1, "flags": 8, "kind": 164, "localSymbol": undefined, "locals": undefined, "modifierFlagsCache": 0, "modifiers": undefined, "name": {"end": -1, "escapedText": "category", "flags": 8, "kind": 79, "modifierFlagsCache": 0, "originalKeywordKind": undefined, "parent": undefined, "pos": -1, "transformFlags": 0}, "nextContainer": undefined, "parent": undefined, "pos": -1, "questionToken": undefined, "symbol": undefined, "transformFlags": 1, "type": {"end": -1, "flags": 8, "kind": 194, "literal": {"end": -1, "flags": 8, "hasExtendedUnicodeEscape": undefined, "kind": 10, "modifierFlagsCache": 0, "parent": undefined, "pos": -1, "singleQuote": undefined, "text": "convert", "transformFlags": 0}, "modifierFlagsCache": 0, "parent": undefined, "pos": -1, "transformFlags": 1}}], "modifierFlagsCache": 0, "parent": undefined, "pos": -1, "transformFlags": 1}, "typeParameters": undefined}
    Received: undefined

      27 |     )
      28 |
    > 29 |     expect(actual).toBe(expected)
         |                    ^
      30 |   })
      31 | })
      32 |

      at Object.<anonymous> (index.test.ts:29:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        4.708 s
Ran all test suites.

createTypeAlias関数に返り値がないためでエラーが出ました。次はこのテストが最短で通るようにcreateTypeAliasを実装します。

createType 関数のテストを最短で通す

では、createType を実装してレッドな(落ちている)テストをグリーンにします。

//index.ts
import { factory } from 'typescript'

export const createTypeAlias = () => {
  return factory.createTypeAliasDeclaration(
    undefined,
    undefined,
    factory.createIdentifier("ConvertStart"),
    undefined,
    factory.createTypeLiteralNode([
      factory.createPropertySignature(
        undefined,
        factory.createIdentifier("action"),
        undefined,
        factory.createLiteralTypeNode(factory.createStringLiteral("convert_start"))
      ),
      factory.createPropertySignature(
        undefined,
        factory.createIdentifier("category"),
        undefined,
        factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
      )
    ])
  )
}

先程の factory 関数で生成したものと同様のオブジェクトを返せばテストは通るので、factory 関数をコピペします。

この状態でテストを実行します。

$ yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts (6.442 s)
  createTypeAlias で型作成のための値から type alias を作成する
    ✕ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (15 ms)

  ● createTypeAlias で型作成のための値から type alias を作成する › 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する

    expect(received).toBe(expected) // Object.is equality

    If it should pass with deep equality, replace "toBe" with "toStrictEqual"

    Expected: {(省略)}
    Received: serializes to the same string

      33 |     )
      34 |
    > 35 |     expect(actual).toBe(expected)
         |                    ^
      36 |   })
      37 | })
      38 |

      at Object.<anonymous> (index.test.ts:35:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        6.81 s
Ran all test suites.

テストが落ちてしまいました。よく見るとエラーメッセージが表示されていますね。

expect(received).toBe(expected) // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"

アサーションにはtoBeではなくtoStrictEqual を使えば良いということです。

アサーションを書き換えて再度テストを実行しましょう。

// index.test.ts
- expect(actual).toBe(expected)
+ expect(actual).toStrictEqual(expected)
$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts (10.967 s)
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        11.602 s
Ran all test suites.
✨  Done in 14.56s.

これでテストが通りました!

TDD ではテストを何度も実行することでその結果からフィードバックを即座に得られます。そして、得られたフィードバックを元に実装を進めていきます。

フィードバックに従って実装することで、浮き足立たず脇道に逸れず、一歩ずつ安全な道を進んでいるという感覚を得られます。

新しいテストケースを作成する

先ほどは ConvertStart型でテストケースを作成したので、次はConvertEnd型のテストを作成します。

以下の型を AST Viewer に入力して factory 関数を手に入れます。

type ConvertEnd = {
  action: 'convert_end'
  category: 'convert'
}

テストケースを増やします。

// index.test.ts
import { factory } from 'typescript'

import { createType } from './index.ts'

describe('...', () => {
  // ...

  test('型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する', () => {
    const actual = createTypeAlias()
    /**
     * type ConvertEnd = {
     *   action: 'convert_end'
     *   category: 'convert'
     * }
     */
    const expected = factory.createTypeAliasDeclaration(
      undefined,
      undefined,
      factory.createIdentifier("ConvertEnd"),
      undefined,
      factory.createTypeLiteralNode([
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("action"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert_end"))
        ),
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("category"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
        )
      ])
    )

    expect(actual).toStrictEqual(expected)
  })
})

テストを実行します。

$  yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (5 ms)
    ✕ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (12 ms)

  ● createTypeAlias で型作成のための値から type alias を作成する › 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する

    expect(received).toStrictEqual(expected) // deep equality

    - Expected  - 2
    + Received  + 2

    @@ -7,11 +7,11 @@
        "name": IdentifierObject {
    -     "escapedText": "ConvertEnd",
    +     "escapedText": "ConvertStart",
    @@ -65,11 +65,11 @@
    -             "text": "convert_end",
    +             "text": "convert_start",

      65 |     )
      66 |
    > 67 |     expect(actual).toStrictEqual(expected)
         |                    ^
      68 |   })
      69 | })
      70 |

      at Object.<anonymous> (index.test.ts:67:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        4.168 s, estimated 11 s
Ran all test suites.

1つ目のテストは通り、2つ目のテストは落ちましたね。

では、2つ目のテストが通るようにcreateTypeAliasの実装を進めていきましょう。

createTypeAlias が2つ目のテストに通る実装をする

createTypeAlias で引数を受け取るようにします。まずはテストを書き換えましょう。

// index.test.ts
import { factory } from 'typescript'

import { createType } from './index.ts'

describe('...', () => {
  test('...', () => {
    const actual = createTypeAlias(
      'ConvertStart',
      {
        action: 'convert_start',
        category: 'convert',
      }
    )

    // ...
  })

  test('...', () => {
    const actual = createTypeAlias(
      'ConvertEnd',
      {
        action: 'convert_end',
        category: 'convert',
      }
    )

    // ...
  })
})

テストを実行します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts
  ● Test suite failed to run

    index.test.ts:8:7 - error TS2554: Expected 0 arguments, but got 2.

      8       'ConvertStart',
              ~~~~~~~~~~~~~~~
      9       {
        ~~~~~~~
    ... 
     11         category: 'convert',
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     12       }
        ~~~~~~~
    index.test.ts:46:7 - error TS2554: Expected 0 arguments, but got 2.

     46       'ConvertEnd',
              ~~~~~~~~~~~~~
     47       {
        ~~~~~~~
    ... 
     49         category: 'convert',
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     50       }
        ~~~~~~~

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        7.468 s
Ran all test suites.

createTypeAliasは引数を受け取らないのでエラーが出ていますね。createTypeAliasで引数を受け取るように改修します。

// index.ts
import { factory, TypeAliasDeclaration } from 'typescript'

type CreateTypeAlias = (name: string, property: Record<string, string>) => TypeAliasDeclaration

export const createTypeAlias: CreateTypeAlias = (name, property) => {
  return ...
}

テストを実行します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts (5.99 s)
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (8 ms)
    ✕ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (30 ms)

  ● createTypeAlias で型作成のための値から type alias を作成する › 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する

    expect(received).toStrictEqual(expected) // deep equality

    - Expected  - 2
    + Received  + 2

    @@ -7,11 +7,11 @@
        "name": IdentifierObject {
    -     "escapedText": "ConvertEnd",
    +     "escapedText": "ConvertStart",
    @@ -65,11 +65,11 @@
    -             "text": "convert_end",
    +             "text": "convert_start",

      77 |     )
      78 |
    > 79 |     expect(actual).toStrictEqual(expected)
         |                    ^
      80 |   })
      81 | })
      82 |

      at Object.<anonymous> (index.test.ts:79:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        6.209 s
Ran all test suites.

テストは落ちていますが、エラーメッセージが変わりました。引数を受け取れている証拠です。

なお、このステップではあえてテストを実行していましたが、TypeScript を使っているので実際はテスト実行前に IDE 上で型エラーが表示されます。

IDE上で型エラーが表示されている

S2554: Expected 0 arguments, but got 2.

型エラーも開発者に対するフィードバックの一つです。型エラーのおかげでテストを実行する時間を節約でき、実装のサイクルが早くなります。

そこで、以下ではテストを変更して型エラーが出た場合は、テストの実行をスキップして型エラーを元に実装を変更していきます。

そして、テストは型エラーがなくなった段階で実行するようにします。

さて、今は型エラーがないので次に先程落ちたテストが通るように関数を変更していきます。

よく見るとfactory.createTypeLiteralNodeは配列を受け取ります。これを利用して以下のように実装してみましょう。

// index.ts
import { factory, TypeAliasDeclaration } from 'typescript'

type CreateTypeAlias = (name: string, property: Record<string, string>) => TypeAliasDeclaration

export const createTypeAlias: CreateTypeAlias = (name, property) => {
  const nodes = Object.entries(property).map((p: [string, string]) => {
    const [key, value] = p

    return factory.createPropertySignature(
      undefined,
      factory.createIdentifier(key),
      undefined,
      factory.createLiteralTypeNode(factory.createStringLiteral(value))
    )
  })

  return factory.createTypeAliasDeclaration(
    undefined,
    undefined,
    factory.createIdentifier(name),
    undefined,
    factory.createTypeLiteralNode(nodes)
  )
}

テストを実行します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (4 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        5.088 s, estimated 6 s
Ran all test suites.
✨  Done in 7.16s.

2つのテストが通りました!

ここで、改めて出力する型を確認しましょう。

// types.ts
type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}

type ConvertEnd = {
  action: 'convert_end'
  category: 'convert'
}

type ConvertError = {
  action: 'convert_error'
  category: 'convert'
}

type Event = (ConvertStart | ConvertEnd | ConvertError) & {
  label?: Record<string, string | number | boolean>
  value?: string
}

ConvertErrorConvertStartConvertEndと似ているため、新たにテストを書いて実装を変更する必要はなさそうです。

Event型は全く異なるので後ほど別の生成関数を作るという方針にします。

AST を文字列に変換する

これで AST を作成できました。

  • yaml ファイルを読み込み、パースして配列にする
  • 配列から型作成に必要な値を抜き出す
  • 型作成のための値から AST を作成する
  • AST を文字列に変換する
  • 文字列を ts ファイルに出力する

AST が作成できたので、文字列に変換するところから出力まで一気にやってしまいます。

AST を文字列に変換する関数名はprintとして、まずテストを作成します。

// index.test.ts
import { factory } from 'typescript'

import { createTypeAlias, print } from './'

describe(...)

describe('print で AST を文字列に変換する', () => {
  test('ConvertStart の AST を与えたとき、ConvertStart 型を出力する', () => {
    const arg = factory.createTypeAliasDeclaration(
      undefined,
      undefined,
      factory.createIdentifier("ConvertStart"),
      undefined,
      factory.createTypeLiteralNode([
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("action"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert_start"))
        ),
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("category"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
        )
      ])
    )
    const actual = print(arg)
    const expected = `type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}`

    expect(actual).toBe(expected)
  })
})

TS で print 関数が存在しないというエラーが出ているため、実装していきます。引数や引数の型などのエラーも合わせて表示されるので、型エラーがなくなるまで一気に実装します。

createTypeAliasDeclarationの返り値の型がTypeAliasDeclarationであるため、print関数はこの型の値を受け取るようにします。

// index.ts
import { factory, TypeAliasDeclaration } from 'typescript'

// ...

type Print = (ast: TypeAliasDeclaration) => string
export const print: Print = (ast) => {
  return ''
}

テストを実行します。

yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (4 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (2 ms)
  print で AST を文字列に変換する
    ✕ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (3 ms)

  ● print で AST を文字列に変換する › ConvertStart の AST を与えたとき、ConvertStart 型を出力する

    expect(received).toBe(expected) // Object.is equality

    Expected: "type ConvertStart = {
      action: 'convert_start'
      category: 'convert'
    }"
    Received: ""

      109 | }`
      110 |
    > 111 |     expect(actual).toBe(expected)
          |                    ^
      112 |   })
      113 | })
      114 |

      at Object.<anonymous> (index.test.ts:111:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        3.562 s
Ran all test suites.

テストが落ちましたね。ではテストが通るようにprintを実装していきましょう。

AST を文字列に変換する処理は ドキュメント を参考にします。

// index.ts

import * as ts from 'typescript'

// type CreateTypeAlias
// ...

const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile('index.ts', '', ts.ScriptTarget.Latest)

type Print = (ast: ts.TypeAliasDeclaration) => string
export const print: Print = (ast) => {
  return printer.printNode(ts.EmitHint.Unspecified, ast, source)
}

printNodeの第三引数には適当な sourceFile を渡しています。この関数のコメントには以下のように書いています。

sourceFile – A source file that provides context for the node.
 The source text of the file is used to emit the original source content for literals and identifiers,
 while the identifiers of the source file are used when generating unique names to avoid collisions.

私自身あまり理解していないのですが、今回はsourceFileの内容は特に気にしなくてよさそうです(詳しい方、コメント頂けると幸いです)。

また、ts 由来のモジュールが増えてきたので import の書き方を以下のように変更しています。

import * as ts from 'typescript'

これでテストを実行します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 FAIL  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (3 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (1 ms)
  print で AST を文字列に変換する
    ✕ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (7 ms)

  ● print で AST を文字列に変換する › ConvertStart の AST を与えたとき、ConvertStart 型を出力する

    expect(received).toBe(expected) // Object.is equality

    - Expected  - 3
    + Received  + 3

      type ConvertStart = {
    -   action: 'convert_start'
    +     action: "convert_start";
    -   category: 'convert'
    +     category: "convert";
    - }
    + };

      109 | }`
      110 |
    > 111 |     expect(actual).toBe(expected)
          |                    ^
      112 |   })
      113 | })
      114 |

      at Object.<anonymous> (index.test.ts:111:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        3.058 s, estimated 5 s
Ran all test suites.

テストが落ちましたね。エラーの内容をよく見ると以下の理由だとわかります。

  • クオーテーションが違う
  • 行終わりの;の有無
  • インデント

今回は自動生成が目的でありコードスタイルは後から変えられるので、ご法度だとは理解しつつも少し目を瞑ってテストの期待している結果を書き換えましょう。

// index.test.ts

// before
const expected = `type ConvertStart = {
  action: 'convert_start'
  category: 'convert'
}`

// after
const expected = `type ConvertStart = {
    action: "convert_start";
    category: "convert";
};`

テストを実行します。

$ yarn test
yarn run v1.22.10
$ jest
PASS  ./index.test.ts (7.855 s)
createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (7 ms)
✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (2 ms)
print で AST を文字列に変換する
    ✓ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (3 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        7.974 s
Ran all test suites.
✨  Done in 9.49s.

テストが通ったので次のステップに進みます。

Event 型を作成する

Event 型はそれほど複雑ではないので、factory 関数で生成せずに文字列操作で生成するようにします。

export type Event = (ConvertStart | ConvertEnd | ConvertError) & {
  label?: Record<string, string | number | boolean>
  value?: string
}

テストを作成します。

// index.test.ts

import { createTypeAlias, print, createEventType } from './'

// describe ...

describe('createEventType', () => {
  test('ConvertStart と ConvertEnd を受け取ったとき、その二つを持った Event 型を生成する', () => {
    const arg = ['ConvertStart', 'ConvertEnd']
    const actual = createEventType(arg)
    const expected = `export type Event = (ConvertStart | ConvertEnd) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
};`

    expect(actual).toBe(expected)
  })
})

実装は以下のようにします。

// index.ts

// ...

const createEventType = (names: string[]): string => {
  return `export type Event = (${names.join(' | ')}) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
};`
}

テストを実行します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (4 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (1 ms)
  print で AST を文字列に変換する
    ✓ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (5 ms)
  createEventType
    ✓ ConvertStart と ConvertEnd を受け取ったとき、その二つを持った Event 型を生成する

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        5.047 s, estimated 10 s
Ran all test suites.
✨  Done in 6.50s.

テストが通りました!

文字列を ts ファイルに出力する

これで AST を文字列に変換できました。

  • yaml ファイルを読み込み、パースして配列にする
  • 配列から型作成に必要な値を抜き出す
  • 型作成のための値から AST を作成する
  • AST を文字列に変換する
  • 文字列を ts ファイルに出力する

ここからの処理は重くないものばかりです。

文字列を ts ファイルに出力する前に、createType関数を作成してこれまでで作成した文字列を結合させましょう。

テストを書きます。

// index.ts

describe('createType', () => {
  test('型名と property を与えたとき、引数の型との交差型 Event 型を生成する', () => {
    const arg = [
      {
        name: 'ConvertStart',
        property: {
          action: 'convert_start',
          category: 'convert',
        }
      },
      {
        name: 'ConvertEnd',
        property: {
          action: 'convert_end',
          category: 'convert',
        },
      },
    ]
    const actual = createType(arg)
    const expected = `type ConvertStart = {
    action: "convert_start";
    category: "convert";
};

type ConvertEnd = {
    action: "convert_end";
    category: "convert";
};

export type Event = (ConvertStart | ConvertEnd) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
};`

    expect(actual).toBe(expected)
  })
})

そこまで複雑ではないので、一気にcreateTypeを実装します。

// index.ts

// ...

type TypeAlias = {
  name: string;
  property: Record<string, string>
}

export const createType = (aliases: TypeAlias[]): string => {
  const types = aliases.map(({ name, property }) => createTypeAlias(name, property))
    .map(print)
    .join('\n\n')

  const names = aliases.map(({ name }) => name)
  const eventType = createEventType(names)

  return [types, eventType].join('\n\n')
}

テストが通ることを確認します。

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (3 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (2 ms)
  print で AST を文字列に変換する
    ✓ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (1 ms)
  createEventType
    ✓ ConvertStart と ConvertEnd を受け取ったとき、その二つを持った Event 型を生成する (1 ms)
  createType
    ✓ 型名と property を与えたとき、引数の型との交差型 Event 型を生成する (1 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        5.193 s
Ran all test suites.
✨  Done in 7.26s.

次に、main 関数を作って型情報をファイルに出力しましょう。

// index.ts

import * as ts from 'typescript'
import * as fs from 'fs'

// ...

const main = () => {
  const inputs = [
    {
      name: 'ConvertStart',
      property: {
        action: 'convert_start',
        category: 'convert',
      }
    },
    {
      name: 'ConvertEnd',
      property: {
        action: 'convert_end',
        category: 'convert',
      },
    },
  ]

  const output = createType(inputs)

  fs.writeFileSync('./types.ts', output)
}

main()

mainを実行します。

$ yarn node --loader ts-node/esm index.ts

ディレクトリを確認するとtypes.tsが生成されていることがわかります。

// types.ts
type ConvertStart = {
  action: "convert_start";
  category: "convert";
};

type ConvertEnd = {
  action: "convert_end";
  category: "convert";
};

export type Event = (ConvertStart | ConvertEnd) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
}

これで型情報をファイルに出力できました。

yaml から値を読み取る

あとは yaml から値を読み取るようにして、自由に型情報を定義できるようにするだけですね。

  • yaml ファイルを読み込み、パースして配列にする
  • 配列から型作成に必要な値を抜き出す
  • 型作成のための値から AST を作成する
  • AST を文字列に変換する
  • 文字列を ts ファイルに出力する

上2つは一気にやってしまいましょう。node で yaml を扱うためのモジュールをインストールします。

$ yarn add js-yaml
$ yarn add -D @types/js-yaml

events.yaml を作成し、イベントの定義を記述します。createTypeの引数の型であるTypeAlias[]型と互換性を持つように yaml を定義します。

type TypeAlias = {
  name: string;
  property: Record<string, string>
}
# events.yaml
- name: ConvertStart
  property:
    action: 'convert_start'
    category: 'convert'

- name: ConvertEnd
  property:
    action: 'convert_end'
    category: 'convert'

- name: ConvertError
  property:
    action: 'convert_error'
    category: 'convert'

次にドキュメント 通りに yaml を読み取る処理を追加します。

// index.ts

import * as yaml from 'js-yaml'

const main = () => {
  try {
    const file = fs.readFileSync('./events.yaml', 'utf8')
    const input = yaml.load(file);
    console.log(input);
  } catch (e) {
    console.log(e);
  }

  // ...
}

試しにinputを出力すると以下のような結果になります。

[
  {
    name: 'ConvertStart',
    property: { action: 'convert_start', category: 'convert' }
  },
  {
    name: 'ConvertEnd',
    property: { action: 'convert_end', category: 'convert' }
  },
  {
    name: 'ConvertError',
    property: { action: 'convert_error', category: 'convert' }
  }
]

この値は意図通りTypeAlias[]型ですね。

では最後の仕上げです。yaml.loadの返り値をcreateType関数に渡しましょう。

// index.ts

// ...

const main = () => {
  let input: TypeAlias[] = []

  try {
    const file = fs.readFileSync('./events.yaml', 'utf8')
    input = yaml.load(file) as TypeAlias[];
  } catch (e) {
    console.log(e);
    return
  }

  const output = createType(input)

  fs.writeFileSync('./types.ts', output)
}

main()

そして、main関数を実行します。するとtypes.tsが出力されます。

// types.ts
type ConvertStart = {
  action: "convert_start";
  category: "convert";
};

type ConvertEnd = {
  action: "convert_end";
  category: "convert";
};

type ConvertError = {
  action: "convert_error";
  category: "convert";
};

export type Event = (ConvertStart | ConvertEnd | ConvertError) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
}

先程のハードコーディングのときとは異なり、ConvertError型が追加されているのがわかりますね。

これで yaml から TypeScript の型を自動生成するプログラムを作成できました!

TODOリストの残りの項目を埋めましょう。

  • yaml ファイルを読み込み、パースして配列にする
  • 配列から型作成に必要な値を抜き出す
  • 型作成のための値から AST を作成する
  • AST を文字列に変換する
  • 文字列を ts ファイルに出力する

プログラムの拡張案

今回生成した型はconvert_startなど yaml で指定したリテラル型でした。

しかし、型定義は string 型や number 型といったプリミティブ型を使ってするのが一般的です。

そこで、そのような変更を加えてみましょう。events.yamlNewEventを追加します。

# events.yaml
- name: NewEvent
  property:
    action: string
    category: number
    hasLabel: boolean

次にcreateTypeAliasを修正します。

export const createTypeAlias: CreateTypeAlias = (name, property) => {
  const nodes = Object.entries(property).map((p: [string, string]) => {
    const [key, value] = p
    // 変更ここから
    let keyword = null

    if (value === 'string') {
      keyword = ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
    } else if (value === 'number') {
      keyword = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword)
    } else if (value === 'boolean') {
      keyword = ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword)
    }

    const type = keyword ?? ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value))

    return ts.factory.createPropertySignature(
      undefined,
      ts.factory.createIdentifier(key),
      undefined,
      type
    )
    // 変更ここまで
  })

  return ts.factory.createTypeAliasDeclaration(
    undefined,
    undefined,
    ts.factory.createIdentifier(name),
    undefined,
    ts.factory.createTypeLiteralNode(nodes)
  )
}

これでファイル生成を実行すると、以下のような型が出力されます。

type NewEvent = {
    action: string;
    category: number;
    hasLabel: boolean;
};

これでより一般的なケースに対応できました!

まとめ

Compiler API を調べ始めていくつかの記事を読んだところ、「使い方は分かったけどこれはどの場面で有用なツールなのだろう」と実際の利用イメージが湧きにくかったため、単なる使い方にとどまらず実務と接続できるような内容を意図して本記事を記述しました。

作成したコードはGitHub で公開しています。 この記事を読んで Compiler API を触ってみようという方がいらっしゃったら幸いです。

また、実際に実務で Compiler API を利用したことを記事にまとめていますので、他にもどんな使い方ができるか気になる方はこちらも合わせてご覧ください。

「TypeScript Compiler API で40の Storybook コンポーネントを storiesOf から CSF(Component Story Format)に置換した」

TDD の利点

また、今回は TDD でプログラムを記述しました。2年前にPHP で TDD を解説した記事を書いた のですが、いつか TypeScript 版の TDD の記事を書きたいなと思っていたので、今回その思いが果たせました。

TDD は、要件に対して必要十分で過不足ないプログラムを作成するための強力な武器です。

最初からゴールが見えているので、過度な一般化や目的達成以外のための無駄な寄り道をすることはありません。コンパイラとテストと対話してプログラムを作り上げるので、エラーをすぐに発見できます。これにより時間と労力の節約になるのです。

しかも、プログラムを組み終わったらテストが残ります。何も知らない他の人が触っても、何を書いたか忘れてしまった1年後の自分が触っても、テストが仕様になっているため安心してプログラムを改修できます。

TDD に興味を持たれた方はぜひケント・ベック氏のテスト駆動開発を読んだり、@t_wada氏によるライブコーディングの動画をご覧ください。

Happy Coding 🎉

コード一覧

今回作成したコードを掲載します。

index.ts

import * as ts from 'typescript'
import * as fs from 'fs'
import * as yaml from 'js-yaml'

type TypeAlias = {
  name: string;
  property: Record<string, string>
}

type CreateTypeAlias = (name: TypeAlias['name'], property: TypeAlias['property']) => ts.TypeAliasDeclaration

export const createTypeAlias: CreateTypeAlias = (name, property) => {
  const nodes = Object.entries(property).map((p: [string, string]) => {
    const [key, value] = p

    return ts.factory.createPropertySignature(
      undefined,
      ts.factory.createIdentifier(key),
      undefined,
      ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value))
    )
  })

  return ts.factory.createTypeAliasDeclaration(
    undefined,
    undefined,
    ts.factory.createIdentifier(name),
    undefined,
    ts.factory.createTypeLiteralNode(nodes)
  )
}

const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
const source = ts.createSourceFile('index.ts', '', ts.ScriptTarget.Latest)

type Print = (ast: ts.TypeAliasDeclaration) => string
export const print: Print = (ast) => {
  return printer.printNode(ts.EmitHint.Unspecified, ast, source)
}

export const createEventType = (names: string[]): string => {
  return `export type Event = (${names.join(' | ')}) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
}`
}

export const createType = (aliases: TypeAlias[]): string => {
  const types = aliases.map(({ name, property }) => createTypeAlias(name, property))
    .map(print)
    .join('\n\n')

  const names = aliases.map(({ name }) => name)
  const eventType = createEventType(names)

  return [types, eventType].join('\n\n')
}

const main = () => {
  let input: TypeAlias[] = []

  try {
    const file = fs.readFileSync('./events.yaml', 'utf8')
    input = yaml.load(file) as TypeAlias[];
  } catch (e) {
    console.log(e);
    return
  }

  const output = createType(input)

  fs.writeFileSync('./types.ts', output)
}

main()

index.test.ts

import { factory } from 'typescript'

import {createTypeAlias, print, createEventType, createType } from './'

/**
 * type ConvertStart = {
 *   action: 'convert_start'
 *   category: 'convert'
 * }
 */
const createConvertStart = () => factory.createTypeAliasDeclaration(
  undefined,
  undefined,
  factory.createIdentifier("ConvertStart"),
  undefined,
  factory.createTypeLiteralNode([
    factory.createPropertySignature(
      undefined,
      factory.createIdentifier("action"),
      undefined,
      factory.createLiteralTypeNode(factory.createStringLiteral("convert_start"))
    ),
    factory.createPropertySignature(
      undefined,
      factory.createIdentifier("category"),
      undefined,
      factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
    )
  ])
)

describe('createTypeAlias で型作成のための値から type alias を作成する', () => {
  test('型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する', () => {
    const actual = createTypeAlias(
      'ConvertStart',
      {
        action: 'convert_start',
        category: 'convert',
      }
    )
    const expected = createConvertStart()

    expect(actual).toStrictEqual(expected)
  })

  test('型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する', () => {
    const actual = createTypeAlias(
      'ConvertEnd',
      {
        action: 'convert_end',
        category: 'convert',
      }
    )
    /**
     * type ConvertEnd = {
     *   action: 'convert_end'
     *   category: 'convert'
     * }
     */
    const expected = factory.createTypeAliasDeclaration(
      undefined,
      undefined,
      factory.createIdentifier("ConvertEnd"),
      undefined,
      factory.createTypeLiteralNode([
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("action"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert_end"))
        ),
        factory.createPropertySignature(
          undefined,
          factory.createIdentifier("category"),
          undefined,
          factory.createLiteralTypeNode(factory.createStringLiteral("convert"))
        )
      ])
    )

    expect(actual).toStrictEqual(expected)
  })
})

describe('print で AST を文字列に変換する', () => {
  test('ConvertStart の AST を与えたとき、ConvertStart 型を出力する', () => {
    const arg = createConvertStart()
    const actual = print(arg)
    const expected = `type ConvertStart = {
    action: "convert_start";
    category: "convert";
};`

    expect(actual).toBe(expected)
  })
})

describe('createEventType', () => {
  test('ConvertStart と ConvertEnd を受け取ったとき、その二つを持った Event 型を生成する', () => {
    const arg = ['ConvertStart', 'ConvertEnd']
    const actual = createEventType(arg)
    const expected = `export type Event = (ConvertStart | ConvertEnd) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
}`

    expect(actual).toBe(expected)
  })
})

describe('createType', () => {
  test('型名と property を与えたとき、引数の型との交差型 Event 型を生成する', () => {
    const arg = [
      {
        name: 'ConvertStart',
        property: {
          action: 'convert_start',
          category: 'convert',
        }
      },
      {
        name: 'ConvertEnd',
        property: {
          action: 'convert_end',
          category: 'convert',
        },
      },
    ]
    const actual = createType(arg)
    const expected = `type ConvertStart = {
    action: "convert_start";
    category: "convert";
};

type ConvertEnd = {
    action: "convert_end";
    category: "convert";
};

export type Event = (ConvertStart | ConvertEnd) & {
  label?: Record<string, string | number | boolean>;
  value?: string;
}`

    expect(actual).toBe(expected)
  })
})

テスト結果

$ yarn test                                                                                                                                           
yarn run v1.22.10
$ jest
 PASS  ./index.test.ts (10.297 s)
  createTypeAlias で型作成のための値から type alias を作成する
    ✓ 型名 ConvertStart 、プロパティ・値が与えられたとき、ConvertStart 型の AST を作成する (4 ms)
    ✓ 型名 ConvertEnd 、プロパティ・値が与えられたとき、ConvertEnd 型の AST を作成する (2 ms)
  print で AST を文字列に変換する
    ✓ ConvertStart の AST を与えたとき、ConvertStart 型を出力する (1 ms)
  createEventType
    ✓ ConvertStart と ConvertEnd を受け取ったとき、その二つを持った Event 型を生成する (1 ms)
  createType
    ✓ 型名と property を与えたとき、引数の型との交差型 Event 型を生成する

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        10.526 s
Ran all test suites.
✨  Done in 13.41s.

Discussion