大量のコードを TypeScript の AST で一気に置き換えよう
はじめに
最近数百ファイルに同内容の変更を加える必要があったのですが、ファイルに応じて動的に変わる部分があることから、単純な置換では対応できずどうしようかと悩んでいました。
そんな時 TSKaigi の以下の発表を思い出し、TypeScript の AST を使って一気に置き換える方法を試してみました。
変換対象のコード
今回は以下のようにコンポーネント単位で用意している Storybook ファイルを対象としました。
import type { StoryObj } from '@storybook/react';
import Button from './Button';
export default {
component: Button,
};
...
このコードへ以下のように型注釈を追加したものが完成形です。
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';
const meta: Meta<typeof Button> = {
component: Button,
};
export default meta;
...
変換したい箇所と内容をまとめると以下の通りです。
-
@storybook/react
のimport
文にMeta
を追加する -
export default
のオブジェクトを meta 変数に変更し、型注釈を設定する- 相対パスで
import
しているコンポーネントの型を指定する
- 相対パスで
-
export default
を meta オブジェクトに変更する
コンポーネントの型が動的に変わりますが、変更内容は共通しているため、AST を使って一気に変換できそうです。
変換には ts-morph というライブラリを使いました。
ts-morph とは
TypeScript の compiler API をラップして AST 解析をできるライブラリです。解析の上、コードの変更や新規作成もできるので、今回のようなコードの変換に使えますね。
ts-morph を使ってコードを書き換える
まずは ts-morph をインストールします。
# npm
npm i -D ts-morph
# yarn
yarn add -D ts-morph
# pnpm
pnpm add -D ts-morph
試行錯誤しながらですが以下のようなスクリプトを作成しました。
import { ImportDeclaration, ObjectLiteralExpression, Project, SourceFile, SyntaxKind } from 'ts-morph';
const project = new Project({
tsConfigFilePath: './tsconfig.json',
});
const unshiftMetaImport = (storyImport: ImportDeclaration): void => {
const namedImports = storyImport.getNamedImports();
const hasMetaImport = namedImports.some((namedImport) => namedImport.getName() === 'Meta');
if (!hasMetaImport) {
storyImport.insertNamedImport(0, 'Meta');
}
};
const getDefaultExportObject = (sourceFile: SourceFile): ObjectLiteralExpression | undefined => {
const defaultExport = sourceFile.getDefaultExportSymbol();
return defaultExport?.getValueDeclaration()?.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
};
const replaceDefaultExport = (
defaultExportObject: ObjectLiteralExpression,
componentImport: ImportDeclaration,
): void => {
const componentName = componentImport.getDefaultImport()?.getText();
const exportAssignment = defaultExportObject.getParentIfKind(SyntaxKind.ExportAssignment);
if (exportAssignment) {
exportAssignment.replaceWithText(
`const meta: Meta<typeof ${componentName}> = ${defaultExportObject.getFullText()};\nexport default meta;`,
);
}
};
const sourceFiles = project.addSourceFilesAtPaths('src/Components/**/*.stories.tsx');
sourceFiles.forEach((sourceFile) => {
const storyImport = sourceFile.getImportDeclaration('@storybook/react');
if (storyImport) {
unshiftMetaImport(storyImport);
}
const defaultExportObject = getDefaultExportObject(sourceFile);
if (!defaultExportObject) {
console.error(`${sourceFile.getFilePath()}: Default export object is replaced.`);
return;
}
const componentImport = sourceFile.getImportDeclarations().find((importDeclaration) => {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue();
return moduleSpecifier.startsWith('./') && importDeclaration.getDefaultImport();
});
if (!componentImport) {
console.error(`${sourceFile.getFilePath()}: Component import not found.`);
return;
}
replaceDefaultExport(defaultExportObject, componentImport);
sourceFile.saveSync();
});
スクリプトについて
対象のファイルを指定する
const project = new Project({
tsConfigFilePath: './tsconfig.json',
});
ts-morph ではまず Project
クラスを使って、指定した tsconfig.json を元に AST を解析できるようにします。
const sourceFiles = project.addSourceFilesAtPaths('src/components/**/*.stories.tsx');
デフォルトでは tsconfig.json でコンパイル対象としたファイルを解析対象とするため、特定のファイルに絞りたい場合は addSourceFilesAtPaths
メソッドで指定します。
今回は Storybook ファイルに限定しています。
変換処理
対象のファイルを設定したら、それぞれのファイルに対して変換処理をかけていきます。
おさらいですが、今回の変換内容は以下の通りです。
@storybook/react
のimport
文にMeta
を追加するexport default
のオブジェクトを meta 変数に変更し、型注釈を設定する
- 相対パスでimport
しているコンポーネントの型を指定するexport default
を meta オブジェクトに変更する
ここから AST で対象箇所を取得していく必要がありますが、ここで役立つのが TypeScript AST Viewer です。
このツールに TypeScript のコードを入力すると以下のように AST が可視化されるので、対象箇所が AST 上でどのように表現されているかを確認できます。
まず、@storybook/react
の import
文、AST 上では ImportDeclaration
として表現されるものを取得します。
その NamedImports
部分に Meta
を追加します。
const unshiftMetaImport = (storyImport: ImportDeclaration): void => {
const namedImports = storyImport.getNamedImports();
const hasMetaImport = namedImports.some((namedImport) => namedImport.getName() === 'Meta');
if (!hasMetaImport) {
storyImport.insertNamedImport(0, 'Meta');
}
};
...
sourceFiles.forEach((sourceFile) => {
const storyImport = sourceFile.getImportDeclaration('@storybook/react');
if (storyImport) {
unshiftMetaImport(storyImport);
}
...
次に、export default
のオブジェクトを取得し、型注釈を追加した meta 変数に変換します。
コードの詳細は割愛しますが、やることは変わらず、AST 上での表現を確認しながら対象箇所を取得し、変換する...の繰り返しです。
ChatGPT や GitHub Copilot でサンプルコードを生成して試していくのが効率的ですが、AST に対応するメソッドを探す場合は以下の詳細ページが参考になります。
const getDefaultExportObject = (sourceFile: SourceFile): ObjectLiteralExpression | undefined => {
const defaultExport = sourceFile.getDefaultExportSymbol();
return defaultExport?.getValueDeclaration()?.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
};
const replaceDefaultExport = (
defaultExportObject: ObjectLiteralExpression,
componentImport: ImportDeclaration,
): void => {
const componentName = componentImport.getDefaultImport()?.getText();
const exportAssignment = defaultExportObject.getParentIfKind(SyntaxKind.ExportAssignment);
if (exportAssignment) {
exportAssignment.replaceWithText(
`const meta: Meta<typeof ${componentName}> = ${defaultExportObject.getFullText()};\nexport default meta;`,
);
}
};
sourceFiles.forEach((sourceFile) => {
...
const defaultExportObject = getDefaultExportObject(sourceFile);
if (!defaultExportObject) {
console.error(`${sourceFile.getFilePath()}: Default export object is replaced.`);
return;
}
const componentImport = sourceFile.getImportDeclarations().find((importDeclaration) => {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue();
return moduleSpecifier.startsWith('./') && importDeclaration.getDefaultImport();
});
if (!componentImport) {
console.error(`${sourceFile.getFilePath()}: Component import not found.`);
return;
}
replaceDefaultExport(defaultExportObject, componentImport);
...
最後に sourceFile.saveSync()
で変更を保存します。
...
sourceFile.saveSync();
});
実行
スクリプトを実行すると、実際に変換されます。
npx tsx transform.ts
まとめ
ChatGPT に聞きながら試していきましたが 30 分ほどでスクリプトを完成させ、一気に変換できました。
AST を使うことで効率的に対応できるので、今後も積極的に活用していきたいです。
Discussion