はじめに
こんにちは。サイバーエージェントのソフトウェアエンジニアの平井柊太(@did0es)です。サイバーエージェントでは、TypeScriptのNext ExpertsとしてTypeScript関連技術のキャッチアップや啓蒙活動を行っています。
本連載では、TypeScriptに関連した静的解析ツールを紹介します。また、ツールを自らの手で再現することで仕組みを深堀りします。TypeScriptの基本的な書き方への理解があれば読み進められます。
TypeScriptはJavaScriptのスーパーセットとして、型や様々な構文により開発効率や保守性の向上をもたらしています。TypeScriptのコンパイラによる型検査と併せて、LinterやFormatterなどの静的解析ツールを活用することで堅牢なプロジェクトを構築できます。本連載では、こういった静的解析ツールを自らの手で再現することで仕組みを深堀りします。
静的解析に欠かせない抽象構文木(以下、AST)の構造や解析方法を学びながら、TypeScriptでLinterやFormatterのようなCLIを実装する内容となっています。一見難しそうなASTですが、理解を深めることでリファクタリングのような作業をソースコードで制御できるようになり、開発効率や再現性が向上します。
ASTを理解するための基本用語
はじめに、理解しておくと本連載を読み進めるにあたって役に立つ用語を整理します。
LinterとFormatter
Linterは構文ミスや非推奨の書き方など、ソースコード上の問題を検出します。Formatterは不要な改行や空白の削除、長い行の折り返しなどを行い、ソースコードを整形します。どちらも、活用することで自動的にソースコードの品質維持が行われ、開発効率が向上します。
これらのツールのうち、TypeScriptと併せてよく用いられるものを紹介します。
・ESLint
ESLintは、JavaScriptで記述されたJavaScript向けのLinterです。以下でファイルをLintできます。
eslint "**/*.js"
Lintはルールに基づいて行われます。ルールとはソースコードが期待値を満たしているか検証し、期待値を満たしていない場合の振る舞いを決定するものです。
例えば、no-consoleルールはconsoleオブジェクトの使用を制限するか、しないかを設定できます。このルールを有効化すると、ブラウザのコンソールやサーバーのログへの不要な情報の出力を防ぎます。
ルールはESLintに組み込まれているもののほか、サードパーティ3rd Party製のプラグインが利用できます。TypeScriptのソースコードのLintには、3rd Party製の@typescript-eslint/eslint-pluginを利用します。
ルールやプラグインは、以下のようなeslint.config.jsに記述して適用します。ES Modules形式で記述していますが、CommonJS形式でも記述できます。
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
/** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigFile} */
export default [
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
rules: {
"no-console": "error"
},
},
];
また、fixオプションを付けると自動で問題の修正を行えます。
eslint --fix "**/*.js"
・Prettier
PrettierはJavaScriptで記述されたFormatterです。JavaScriptやTypeScriptのほか、YAMLやJSONなどもサポートしています。以下でファイルをFormatできます。
prettier --write "**/*.{js,ts,yaml,json}"
Prettierはオプションによって、Formatの振る舞いが変わります。例えば、—no-semiオプションで行末にセミコロンを付けるか付けないかを制御できます。PrettierのCLIのオプションとして使えるほか、以下のような.prettierrcに記述もできます。
{
"semi": false
}
PrettierとESLintは併用できます。PrettierのFormat結果がESLintのLintでエラーにならないようなインテグレーションが存在します。
eslint-config-prettierでPrettierの設定と競合するESLintのルールを無効化できます。また、eslint-plugin-prettierを用いると、PrettierがFormatの対象にする書き方をLintのエラーとして検出できます。
併用により、ソースコードのバグを減らしつつ体裁も整えられ、開発効率が向上します。
・Biome
BiomeはRustで記述されたLinter兼Formatterです。ESLintでよく用いられるルールをそのまま利用できるほか、Prettierのほとんどの機能と互換性があります。
以下でLintとFormatを併せて行えます。
npx @biomejs/biome check --write "**/*.js"
設定はbiome.jsonに記述します。
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": { "ignoreUnknown": false, "ignore": [] },
"formatter": { "enabled": true, "indentStyle": "tab" },
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": { "recommended": true }
},
"javascript": { "formatter": { "quoteStyle": "double" } }
}
最大の特徴として、ESLintやPrettierよりも高速に実行できる点が挙げられます。BiomeのベンチマークによるとPrettierの25倍の速さでFormatを、ESLintの15倍の速さでLintを行えます。
内部では、具象構文木(以下、CST)というデータ構造でソースコードを扱います。CSTはなるべく元のソースコードの情報を落とさないように変換された木構造のデータです。CSTを用いて問題の検出精度を向上させています。
また、CSTの生成にはrust-analyzer製のrowanをforkして利用しており、高速かつ堅牢な動作を実現しています。
本連載の後半で、これらのツールのように設定ファイルを読み込み、ソースコードの検証や修正を行うCLIを開発します。
Abstract Syntax Tree:抽象構文木(AST)
ASTはソースコードを変換して得られる中間表現の一種です。本連載におけるASTは、JavaScriptとTypeScriptのASTを指します。ソースコードをASTに変換すると、ただの文字の羅列ではなく木構造で扱え、効率良く全体を走査できるなどの恩恵が得られます。
実際のASTを見てみましょう。例えば、AcornというJavaScriptパーサーでconsole.log関数をパースすると、以下のASTを得られます。
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 11,
"expression": {
"type": "MemberExpression",
"start": 0,
"end": 11,
"object": {
"type": "Identifier",
"start": 0,
"end": 7,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 8,
"end": 11,
"name": "log"
},
"computed": false,
"optional": false
}
}
],
"sourceType": "module"
}
consoleとlogは、それぞれIdentifierというtypeになります。この2つを合わせてExpressionStatementというtypeになります。ソースコード全体はProgramというtypeとして扱われます。
また、ASTには様々な種類があります。前項で紹介したESLintはEspreeというEsprimaに基づいた独自のJavaScriptパーサーに対応したASTを用いています。PrettierはRecastに基づいた独自のJavaScriptパーサーに対応したASTを用いています。
これらのASTについて表現できる構文やプロパティの種類は異なりますが、typeのような構文ごとの識別子が与えられている点は共通しています。このtypeや他のプロパティを頼りにLintやFormatを行います。
それでは、ASTの例として挙げたconsole.log関数のASTに簡単な変換を行ってみましょう。以下のように、logのIdentifierのnameをerrorに書き換えると、console.error関数に対応したASTへ変換できます。
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 11,
"expression": {
"type": "MemberExpression",
"start": 0,
"end": 11,
"object": {
"type": "Identifier",
"start": 0,
"end": 7,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 8,
"end": 11,
"name": "error" // ← log から error に変換
},
"computed": false,
"optional": false
}
}
],
"sourceType": "module"
}
これをソースコードに復元し、ファイルを上書きするとconsole.log関数がconsole.error関数に変更されます。
開発パートでは、以上の処理をTypeScriptでCLIに落とし込みます。
静的解析
ソースコードを実行することなく解析する技術です。解析にはASTを用いる以外に、ソースコードをテキストとみなして正規表現を使う方法もあります。数行程度の簡単な検査はこれで事足りますが、構文上の問題の検出や数多のファイルに及ぶ大規模な検査では限界があります。
TypeScriptのコンパイラやこれまで触れてきたツールは、ソースコードをASTに落とし込み、効率の良い解析を実現しています。本項ではESLintの仕組みを通して、静的解析の方法を深堀ります。
ESLintは下図のコンポーネント群で構成されています。矢印のとおり、ESLintのCLIを実行するとルールが読み込まれ、ソースコードの走査と問題の検出が行われます。
この画像におけるlinterとrulesには、静的解析を活用した機能が備わっています。
linterでは入力されたソースコードをEspreeでASTにパースし、Estraverseというツールで再帰的に探索します。探索の際に見つかったtypeに応じたイベントを発火します。
このイベントは、ブラウザでボタンをクリックしたときなどに発生するイベントと似たもので、Node.jsのEvent emitterが用いられます。
rulesではlinterで発火したイベント駆動で、設定されたルールに基づいてASTの検査が行われます。ここで見つかった問題が報告され、CLIの実行結果として出力されます。
以上のように、ESLintではソースコードをパースして得られたASTを再帰的に探索し、イベント駆動で検査する形で効率よく静的解析が行われています。
CLI開発のための環境構築
ここからは、CLI開発のための環境を整えていきましょう。開発にはmacOS(Sequoia 15.3.2)を利用します。LinuxやWindowsなど、他のOSを用いる場合はDockerを用いた環境構築をお勧めします。
asdfのインストール
Node.jsの公式サイトからも直接インストールできますが、asdfというバージョンマネージャーによるインストール方法を紹介します。
asdfはHomebrew経由でインストールできます。Homebrewが手元にない場合は、以下を実行してください。
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
以下で、asdfをインストールしてください。
brew install asdf
asdfのshimが入るディレクトリにパスを通しましょう。shimはasdfでインストールしたツールを実行するためのシェルスクリプトです。セットアップの項目を参考に、適宜シェルの設定ファイルに変更を加えてください。今回はZSH向けに~/.zshrcへ以下を加えます。
export PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims:$PATH"
Node.jsをインストール
asdfのセットアップ後、以下でNode.jsのバージョン23.10.0をインストールします。
asdf install nodejs 23.10.0
以下でインストールしたバージョンを有効化します。ホームディレクトリに.tool-versionsファイルが生成され、Node.jsのバージョンが固定されます。バージョンはasdf setコマンドでいつでも変更できます。
asdf set -u nodejs 23.10.0
以下で、Node.jsのバージョンを確認します。
node -v
v23.10.0が出力されたら、セットアップは完了です。
TypeScriptでHello Worldする
Node.js v23.6.0から、TypeScriptランタイムの利用やNode.jsの特殊な設定を行わずにTypeScriptを実行できます。
また、v23.10.0からは実験的な機能としてnode.config.jsonファイルにNode.jsのCLIに渡すフラグを記述できます。
以上の機能を用いて、TypeScriptでHello Worldを出力するソースコードを書いてみましょう。
はじめに作業用のディレクトリを作成します。
mkdir -p build-your-own-ast-tools-by-typescript/001-hello-world
cd build-your-own-ast-tools-by-typescript/001-hello-world
次に、以下を記述したnode.config.jsonファイルを作成します。
{
"$schema": "https://nodejs.org/dist/v23.10.0/docs/node-config-schema.json",
"nodeOptions": {
"disable-warning": ["ExperimentalWarning"],
"experimental-transform-types": true,
"experimental-detect-module": true
}
}
nodeOptionsには、以下のプロパティを指定しています。
-
"disable-warning": ["ExperimentalWarning"]:実験的機能を使った際の警告を無効化するオプション -
"experimental-detect-module": true:自動で実行対象がCommonJSかES Modulesかを判別するフラグ -
"experimental-transform-types": true:EnumやnamespaceのようなTypeScript独自の記法を実行できるように変換するフラグ
Hello Worldを出力するには過剰な設定ですが、次回以降に備えて現段階で設定しておきましょう。同じディレクトリに、以下を記述したhelloWorld.tsファイルを作成します。
const greeting: string = "Hello, World!";
console.log(greeting);
helloWorld.tsファイルを実行します。
node helloWorld.ts --experimental-default-config-file
Hello, World!と出力されれば完了です。お疲れさまでした!
おわりに
今回で紹介したコードはGitHubのリポジトリに掲載しています。併せてご覧ください。次回は、CLIの開発の始め方や、実際にパッケージとしてリリースするまでの過程を紹介します。
【参考文献】- https://eslint.org/docs/latest/use/core-concepts/
- https://eslint.org/docs/latest/contribute/architecture/
- https://eslint.org/blog/2014/12/espree-esprima/
- https://azu.github.io/JavaScript-Plugin-Architecture/ja/ESLint/
- https://prettier.io/docs/why-prettier
- https://prettier.io/docs/technical-details
- https://biomejs.dev/internals/philosophy/
- https://biomejs.dev/internals/architecture
- この記事のキーワード
