Babel で学ぶ TypeScript トランスパイル


何気ない生活の中で「 TypeScript をパースしたい!」という感情が湧き上がって Babel のパーサーをちょっと触ったのも何かの縁なので、 AST とトランスパイラについて書きます。

そもそも AST とは

Abstract Syntax Tree の頭文字を取って AST です。日本語で言うと抽象構文木ですね。アスパラギン酸アミノ基転移酵素ではありません。 フロントエンドエンジニアであれば Tree と聞いて思い出すのは DOM Tree や CCSOM Tree だと思いますが、同じ意味合いの Tree です。 具象構文木から不必要な情報(カッコなど)を取り除いた構文木が抽象構文木であり、構文解析によって作られます。

JavaScript においては、 Babel というライブラリが @babel/parser (故 babel/babylon) というパーサーを提供していて、それを利用することにより誰でも簡単に AST に触れらます。そして、ESTree という標準も用意されており、基本的にパーサーはこの標準に沿っています。(一部のライブラリを除く。まさに Babel 。)

また、AST は ESLint や Prettier など現在のフロントエンド開発を支える多くのツールで利用されていて、 AST を完全に理解すると ESLint のプラグインや Babel のプラグインを作れたりしちゃうわけです。

Warning

  1. 今回は AST とトランスパイラについて触れるだけなので、利用するライブラリの使い方に関しては深く触れません。そのような情報を求めているのであれば、大人しくドキュメントを読んでください。
  2. .mjs を利用している深い理由は特にないので、気にしないでください。そして記載しているコードも殴り書きです。嘘です。綺麗なコード書けません。

トランスパイラ

さて、 早速 AST を使って TypeScript のトランスパイラを作っていきたいと思うわけですが、まずはザックリとトランスパイルの流れを追っていきましょう。ちなみに、別の水準の言語へ変換するコンパイラとは違い、トランスパイルとは同じ水準の言語の変換することを表します。

トランスパイルの流れは、ザックリ3つのフェーズに分かれています。

  1. 解析フェーズ ( @babel/parser
  2. 変換フェーズ( @babel/traverse
  3. 生成フェーズ( @babel/generator

それぞれのフェーズの処理は名前の通りなのですが実際にトランスパイラを作りながら見ていきましょう。

JavaScript トランスパイラ

まずはお試しで JavaScript から JavaScript を生成してみます。 通常は ES6 から ES5 を生成したりしますが、今回は超簡単に変数名を変換してみます。

猿でも書ける JavaScript のファイルを用意して、やっていきましょう。

// index.js
const hoge = "example"

解析フェーズ

index.js のコードを文字列に変換した上で、@babel/parser を利用して解析していきます。今回は明示的にフェーズを分けるために、その中間で生成されるものを JSON ファイルで吐き出していきます。

// parse.mjs
import babelParser from "@babel/parser";
import fs from "fs";

const codeString = fs.readFileSync("./index.js", "utf-8");

const parsedResult = babelParser.parse(codeString, {});

fs.writeFileSync("./parsedResult.json", JSON.stringify(parsedResult));

JSON「で、俺が生まれたってわけ」

// parsedResult.json
{
  "type": "File",
  "start": 0,
  "end": 22,
  "loc": {
    "start": { "line": 1, "column": 0 },
    "end": { "line": 1, "column": 22 }
  },
  "errors": [],
  "program": {
    "type": "Program",
    "start": 0,
    "end": 22,
    "loc": {
      "start": { "line": 1, "column": 0 },
      "end": { "line": 1, "column": 22 }
    },
    "sourceType": "script",
    "interpreter": null,
    "body": [
      {
        "type": "VariableDeclaration",
        "start": 0,
        "end": 22,
        "loc": {
          "start": { "line": 1, "column": 0 },
          "end": { "line": 1, "column": 22 }
        },
        "declarations": [
          {
            "type": "VariableDeclarator",
            "start": 6,
            "end": 22,
            "loc": {
              "start": { "line": 1, "column": 6 },
              "end": { "line": 1, "column": 22 }
            },
            "id": {
              "type": "Identifier",
              "start": 6,
              "end": 10,
              "loc": {
                "start": { "line": 1, "column": 6 },
                "end": { "line": 1, "column": 10 },
                "identifierName": "hoge"
              },
              "name": "hoge"
            },
            "init": {
              "type": "StringLiteral",
              "start": 13,
              "end": 22,
              "loc": {
                "start": { "line": 1, "column": 13 },
                "end": { "line": 1, "column": 22 }
              },
              "extra": { "rawValue": "example", "raw": "\"example\"" },
              "value": "example"
            }
          }
        ],
        "kind": "const"
      }
    ],
    "directives": []
  },
  "comments": []
}

解析した結果こそが、正に AST で、今回はこの情報を元に変換していくというわけです。 index.js 自体はたった一行なので情報量としては少ないですが、逆に考えると1行だけでもこれだけの情報量になるのかとも思えますね。

変換フェーズ

では、早速上記の AST を元に変数名を hoge から fuga に変換するようなコードを書いていきましょう。

// traverse.mjs
import traverse from "@babel/traverse";
import fs from "fs";

const astString = fs.readFileSync("./parsedResult.json", "utf-8");

let ast;

try {
  ast = JSON.parse(astString);
} catch (e) {
  console.log(e);
}

traverse.default(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "hoge" })) {
      path.node.name = "fuga";
    }
  },
});

fs.writeFileSync("./traversedResult.json", JSON.stringify(ast));

path.isIdentifier で name が hoge だった場合に、fuga と書き換えるような処理を書いています。 このあたりのメソッドの使い方はドキュメントを読んだほうがよいので詳しい説明は省略しますが、上記のように AST のオブジェクトを修正して変換していくというわけです。

JSON「そうして、生まれたのが俺ってわけ」

// traversedResult.json
{
  "type": "File",
  "start": 0,
  "end": 22,
  "loc": {
    "start": { "line": 1, "column": 0 },
    "end": { "line": 1, "column": 22 }
  },
  "errors": [],
  "program": {
    "type": "Program",
    "start": 0,
    "end": 22,
    "loc": {
      "start": { "line": 1, "column": 0 },
      "end": { "line": 1, "column": 22 }
    },
    "sourceType": "script",
    "interpreter": null,
    "body": [
      {
        "type": "VariableDeclaration",
        "start": 0,
        "end": 22,
        "loc": {
          "start": { "line": 1, "column": 0 },
          "end": { "line": 1, "column": 22 }
        },
        "declarations": [
          {
            "type": "VariableDeclarator",
            "start": 6,
            "end": 22,
            "loc": {
              "start": { "line": 1, "column": 6 },
              "end": { "line": 1, "column": 22 }
            },
            "id": {
              "type": "Identifier",
              "start": 6,
              "end": 10,
              "loc": {
                "start": { "line": 1, "column": 6 },
                "end": { "line": 1, "column": 10 },
                "identifierName": "hoge"
              },
              "name": "fuga" <------- ここに注目!
            },
            "init": {
              "type": "StringLiteral",
              "start": 13,
              "end": 22,
              "loc": {
                "start": { "line": 1, "column": 13 },
                "end": { "line": 1, "column": 22 }
              },
              "extra": { "rawValue": "example", "raw": "\"example\"" },
              "value": "example"
            }
          }
        ],
        "kind": "const"
      }
    ],
    "directives": []
  },
  "comments": []
}

しっかり fuga に書き換わっているのが見て取れますね。

生成フェーズ

さて、いよいよ変換した AST から JavaScript のコードを生成していきます。 @babel/generator を利用するだけなので、難しいことはありません。

// generate.mjs
import fs from "fs";
import generate from "@babel/generator";

const originalCodeString = fs.readFileSync("./index.js", "utf-8");

const astString = fs.readFileSync("./traversedResult.json", "utf-8");

let ast;

try {
  ast = JSON.parse(astString);
} catch (e) {
  console.log(e);
}

const { code: codeString } = generate.default(ast, {}, originalCodeString);

fs.writeFileSync("./generated.js", codeString);

JavaScript「そんで、俺が生まれたのよ」

// generated.js
const fuga = "example";

しっかり変数名が fuga に変更されています。 まずは流れを掴むということで、少し駆け足になりましたが、上記のような工程を経て、ようやく新しく JavaScript のコードが生まれるのです。

TypeScript トランスパイラ

では、次に TypeScript を JavaScript にトランスパイルをしていきましょう。トランスパイルと聞くと、こっちの方がイメージ強いかもしれません。(個人的な感想)

手始めに、以下のような関係各所から「どこで使うの?」って聞かれそうなメソッドを TypeScript で書きます。

// index.ts
function assgin<T>(someValue?: T): T {
  const value = someValue;
  return value;
}

何の変哲もない、関数ですね。利用シーンが全く思いつきません。 そして、先ほどの parse.mjs のファイルパス周りの変更に加えてオプションに typescript の plugins を追加した上で上記の index.ts を parse した結果が以下のような AST になります。

// parsedResult.json
{
  "type": "File",
  "start": 0,
  "end": 97,
  "loc": {
    "start": { "line": 1, "column": 0 },
    "end": { "line": 5, "column": 0 }
  },
  "errors": [],
  "program": {
    "type": "Program",
    "start": 0,
    "end": 97,
    "loc": {
      "start": { "line": 1, "column": 0 },
      "end": { "line": 5, "column": 0 }
    },
    "sourceType": "script",
    "interpreter": null,
    "body": [
      {
        "type": "FunctionDeclaration",
        "start": 0,
        "end": 96,
        "loc": {
          "start": { "line": 1, "column": 0 },
          "end": { "line": 4, "column": 1 }
        },
        "id": {
          "type": "Identifier",
          "start": 9,
          "end": 13,
          "loc": {
            "start": { "line": 1, "column": 9 },
            "end": { "line": 1, "column": 13 },
            "identifierName": "main"
          },
          "name": "main"
        },
        "generator": false,
        "async": false,
        "params": [
          {
            "type": "Identifier",
            "start": 14,
            "end": 32,
            "loc": {
              "start": { "line": 1, "column": 14 },
              "end": { "line": 1, "column": 32 },
              "identifierName": "someValue"
            },
            "name": "someValue",
            "optional": true,
            "typeAnnotation": {
              "type": "TSTypeAnnotation",
              "start": 24,
              "end": 32,
              "loc": {
                "start": { "line": 1, "column": 24 },
                "end": { "line": 1, "column": 32 }
              },
              "typeAnnotation": {
                "type": "TSStringKeyword",
                "start": 26,
                "end": 32,
                "loc": {
                  "start": { "line": 1, "column": 26 },
                  "end": { "line": 1, "column": 32 }
                }
              }
            }
          }
        ],
        "returnType": {
          "type": "TSTypeAnnotation",
          "start": 33,
          "end": 41,
          "loc": {
            "start": { "line": 1, "column": 33 },
            "end": { "line": 1, "column": 41 }
          },
          "typeAnnotation": {
            "type": "TSStringKeyword",
            "start": 35,
            "end": 41,
            "loc": {
              "start": { "line": 1, "column": 35 },
              "end": { "line": 1, "column": 41 }
            }
          }
        },
        "body": {
          "type": "BlockStatement",
          "start": 42,
          "end": 96,
          "loc": {
            "start": { "line": 1, "column": 42 },
            "end": { "line": 4, "column": 1 }
          },
          "body": [
            {
              "type": "VariableDeclaration",
              "start": 46,
              "end": 78,
              "loc": {
                "start": { "line": 2, "column": 2 },
                "end": { "line": 2, "column": 34 }
              },
              "declarations": [
                {
                  "type": "VariableDeclarator",
                  "start": 52,
                  "end": 77,
                  "loc": {
                    "start": { "line": 2, "column": 8 },
                    "end": { "line": 2, "column": 33 }
                  },
                  "id": {
                    "type": "Identifier",
                    "start": 52,
                    "end": 65,
                    "loc": {
                      "start": { "line": 2, "column": 8 },
                      "end": { "line": 2, "column": 21 },
                      "identifierName": "value"
                    },
                    "name": "value",
                    "typeAnnotation": {
                      "type": "TSTypeAnnotation",
                      "start": 57,
                      "end": 65,
                      "loc": {
                        "start": { "line": 2, "column": 13 },
                        "end": { "line": 2, "column": 21 }
                      },
                      "typeAnnotation": {
                        "type": "TSStringKeyword",
                        "start": 59,
                        "end": 65,
                        "loc": {
                          "start": { "line": 2, "column": 15 },
                          "end": { "line": 2, "column": 21 }
                        }
                      }
                    }
                  },
                  "init": {
                    "type": "StringLiteral",
                    "start": 68,
                    "end": 77,
                    "loc": {
                      "start": { "line": 2, "column": 24 },
                      "end": { "line": 2, "column": 33 }
                    },
                    "extra": { "rawValue": "example", "raw": "\"example\"" },
                    "value": "example"
                  }
                }
              ],
              "kind": "const"
            },
            {
              "type": "ReturnStatement",
              "start": 81,
              "end": 94,
              "loc": {
                "start": { "line": 3, "column": 2 },
                "end": { "line": 3, "column": 15 }
              },
              "argument": {
                "type": "Identifier",
                "start": 88,
                "end": 93,
                "loc": {
                  "start": { "line": 3, "column": 9 },
                  "end": { "line": 3, "column": 14 },
                  "identifierName": "value"
                },
                "name": "value"
              }
            }
          ],
          "directives": []
        }
      }
    ],
    "directives": []
  },
  "comments": []
}

さっきの JavaScript の AST よりもだいぶ情報量が増えていますね。JavaScript を parse したときと違って、TSTypeAnnotation や TSStringKeyword が増えているのが確認できます。 そのようなパラメータが取り除かれた AST があれば、それすなわち JavaScript の AST というわけです。

では、肝心の traverse.mjs を書いていきます。

// traverse.mjs
// 肝心な変換部分のみ記載
//...

traverse.default(ast, {
  enter(path) {
    if(path.isIdentifier()){
        if(path.node.typeAnnotation) path.node.typeAnnotation = null;
        if(path.node.optional) path.node.optional = null;
    }

    if (path.node.type === "FunctionDeclaration") {
      if (path.node.typeParameters) path.node.typeParameters = null;
      if (path.node.returnType) path.node.returnType = null;
    }
  },
});

// ...

Identifier であれば、 typeAnnotation と optional のパラメータを null に、 Function であれば、 typeParameters と returnType を null にする処理を書きました。 今回用意した index.ts は上記で null を代入しているパラメータしか利用していませんが、例えば private や public といったアクセス修飾子があれば accessibility を null にする必要があります。

そして、いざ変換して生成した JavaScript がこちらです。

// generated.js
function assgin(someValue) {
  const value = someValue;
  return value;
}

しっかり、型情報が落ちてくれましたね。 こんな感じで簡単なトランスパイラであれば、 @babel/parser や @babel/traverse を使って簡単に作れてしまうのが現代です。

おわりに

Import や Export は闇が深いので別として、TypeScript については飽くまでも JavaScript のスーパーセットであり、TypeScript から型情報やアクセス修飾子などを取り除けば簡単に JavaScript になります。 ですので、コンパイラのような高級言語からマシンコードを生成するプログラムの仕組みや流れをザックリ理解するには適しているのではないでしょうか。

ちなみに TypeScript のトランスパイラですが、みなさんは実際にどのトランスパイラを利用しているのでしょう。 次世代のコンパイラと自ら謳う Babel、Microsoftが手掛ける tsc、最強のバンドラー webpack など、トランスパイラにも色々と選択肢があります。しかし、それぞれのトランスパイラによって機能の制限があったりパフォーマンスに難があったりと「これを使えばOK」みたいな完全なトランスパイラが存在しない認識です。 今後はそのあたりの差についても実際に TypeScript のコードをトランスパイルして確認していきたいなんて妄想してます。

ちなみに、今回 TypeScript の雑なトランスパイラを書きましたが、 Babel でいうところの @babel/plugin-transform-typescript が同様の実装をしています。 一つのソフトウェアとしてトランスパイラを作る際、具体的にどのようなコードを書けばよいのかは実際に変換周りの処理を行っているこちらのコード周りを読むとよいかもしれません。