🧹

ts-remove-unusedというTypeScriptの不要なコードを自動で削除するツールをつくった

2024/09/21に公開

ts-remove-unusedというTypeScriptの不要なコードを自動で削除するツールを開発しています。

https://github.com/line/ts-remove-unused

あるTypeScriptファイルについて、ファイル内で参照されていない定義を検知することはtscやESLintなど既存のツールで実現できます。一方で、 export の存在を考慮してプロジェクト全体から見た不要なものを判定することはより複雑な問題です。ts-remove-unusedはプロジェクト全体から見た不要なコードを検知するだけではなく、自動でファイルの編集まで行ってくれます。削除の機能まで提供していることがこのツールの新規性です。

このツールについてHacker Newsに投稿したところ、一時的に全体で2位、Show HNの中では1位に浮上し、ここ数日で300を超えるGithub Starを獲得できました。

https://news.ycombinator.com/item?id=41554014

投稿へのコメントをもとにREADMEの内容を増やしましたが、英語の説明の分量が多くなってしまい、日本語のユーザにとっつきにくくなってしまったと感じています。せっかく日本で日本語が母語の作者が開発しているのになんだかなぁという気持ちになったので、READMEの説明をなぞりながら こちらの記事 では扱いきれなかった内容について、Hacker Newsの議論の内容も交えて掘り下げます。

できること

ts-remove-unusedが自動で行う編集を例を使って紹介します。

a2 がプロジェクト内で使われていないとき:

--- src/a.ts
+++ src/a.ts
@@ -1,3 +1 @@
 export const a = 'a';
-
-export const a2 = 'a2';

b がプロジェクト内で使われていないが、 f() は使われているとき:

--- src/b.ts
+++ src/b.ts
@@ -1,5 +1,5 @@
-export const b = 'b';
+const b = 'b';
 
 export function f() {
     return b;
 }

f() がプロジェクト内で使われておらず、削除した場合 import も不要になるケース:

--- src/c.ts
+++ src/c.ts
@@ -1,7 +1 @@
-import { cwd } from "node:process";
-
 export const c = 'c';
-
-export function f() {
-    return cwd();
-}

f()exported がプロジェクト内で使われておらず、 f() を削除した場合に exportedlocal が不要になるケース:

--- src/d.ts
+++ src/d.ts
@@ -1,8 +1 @@
-export const exported = "exported";
-const local = "local";
-
 export const d = "d";
-
-export function f() {
-  return { exported, local };
-}

このほか、ファイルのすべてのexportが使用されていない場合にはファイルごと削除します。例では紹介しませんでしたが、type, interface, class, export default, export { foo }; など、このほかにもさまざまな構文に対応しています。

使われていないとはどういう状態なのか?

不要なコードを自動で削除するためには、使われていないとはどういう状態なのか明確に理解する必要があります。検出するツールの場合は、(最終的な編集はユーザに委ねられるので)偽陽性は許容されますが、削除まで行うツールの場合はユーザがちゃんと理解した上で使うことが求められるのは避けられないのかなと思っています。

ts-remove-unusedは、与えられたTypeScriptファイルの集合について、指定されたファイルから辿ることができない(変数の)定義を「使われていない」として考えます。

たとえば npm create vite で作ったReact + TypeScriptのプロジェクトであれば、フロントエンドのすべてのコードは src/main.tsx が起点になります。逆にいうと、src/main.tsx から辿れないコンポーネントなどの export は、コードベース上に存在していても意味がないということなります。

また、Next.jsでPage Routerを使っている場合には、すべてのコードは pages/ ディレクトリ配下のファイルから辿れるはずです。components/hooks/ といったディレクトリを作って整理することがよくあると思いますが、これらのディレクトリ内のコードは pages/ 配下のどこかから参照されていないと存在している意味がないのは、Viteの場合と同じです。

ライブラリを開発している場合、ユーザに露出するAPIを限定するために lib/main.ts などentrypointを作り、ユーザには import { foo } from 'my-package'; のような形で提供するのが一般的だと思います。この場合も、entrypointから辿れなければ、それは「使われていない」と判定することができると思います。

最近のバンドラはバンドルを生成する際にtree-shakingを行いますが、イメージとしてはそれをソースコードに対して行うことに近いです。

いずれの場合も「使われていない」を正しく判定するためには、正しく起点となるファイルを指定することが必要です。

つかってみる

ts-remove-unusedは tsconfig.json の設定内容によって挙動が大きく左右されます。TypeScript Compilerは includeexclude などの tsconfig.json の情報をパースして、これをもとにプロジェクトのファイルの一覧を保持しています。ts-remove-unusedはこの情報をもとに「使われていない」ことを判定するため、tsconfig.json をメンテナンスして、指定されているTypeScriptファイルの集合を大きすぎず、小さすぎない必要十分なものにする必要があります。(CLIの実行時には --projecttsconfig.json へのファイル名・パスを指定できるので、既存の tsconfig.json をアップデートするほかに新しくts-remove-unused専用のものを用意することも可能です。)

tsconfig.json の設定の結果得られるファイルの集合が小さすぎる場合には、ある定義がほかのファイルから参照されていたとしてもそれを正しく認識できない問題が発生します。

逆に集合が大きすぎる場合にはどうでしょうか。たとえば src/ にアプリケーションのソースコードを保存しているプロジェクトで、ひとつの tsconfig.jsontools/ のファイルまでカバーしている場合について考えてみます。この場合 tools/ の配下のファイルは entrypointファイルからは当然参照されていないでしょうから、不要なものとして削除されてしまいます。ただ、大きすぎる場合には tsconfig.json の設定を見直さなくても、後述する --skip オプションを使って削除を回避することもできます。

tsconfig.json を見直したら、実際につかってみます。プロジェクトにnpm packageをインストールします。

npm i @line/ts-remove-unused

実際の編集は次のようなコマンドで行えます。

npx @line/ts-remove-unused --skip 'src/main\.ts'

前述のとおり、「使われていない」ことを正しく判定するためには適切に entrypointファイルなどのコードの起点となるファイルを指定する必要があります。--skip オプションは正規表現を受け付けます。ここでは src/main.ts というファイルがentrypointだと仮定して、指定してみました。

--check オプション

編集を行わずにts-remove-unusedをリンターとして使用することも可能です。手元のプロジェクトでは、--check モードのコマンドがGitHub Actionsで実行するようにしています。

npx @line/ts-remove-unused --skip 'src/main\.ts' --check

削除可能な定義やファイルが存在する場合には、exit code: 1 で終了します。

--skip オプション

--skip オプションでプロジェクトの起点となるファイルを指定することはマストですが、それ以外にも削除から除外したいファイルのパターンを指定できます。たとえばテストファイルにマッチするパターンを指定することが考えられます。

npx @line/ts-remove-unused --skip 'src/main\.ts' --skip '\.test\.(ts|tsx)$'

ほかにも // ts-remove-unused-skip を使って個別の export を削除の対象から除外することも可能です。

// ts-remove-unused-skip
export const a = 'a'; // 削除されない

現状だとまだ dynamic import構文 import() など対応できていない構文がある(修正済みです)ので、そういったコードの削除を迂回することにも --skip// ts-remove-unused-skip が使えます。

なぜ設定ファイルではなく、 tsconfig.json を使う仕様にしたか?

不要な export を検出するほかのツールのなかには、独自のconfigファイルをプロジェクトのrootディレクトリに置くことを求めるものもあります。個人的に、(ESLintなどのように、汎用的で広く使われているツールでもないのに)導入のために新しいconfigファイルをrootディレクトリに置くこと求めるようなツールが好きじゃないということもありますが、この機能を実現するツールについては特に、コードベースをよりきれいにするものなのにその過程でファイルを追加させる仕様に矛盾を感じました。

tsconfig.json がメンテナンスされていれば特に対応せずとも動くはずで、別に設定することを求めるのは冗長なので、実際にコードベースをメンテナンスする人にとって使いやすいツールを目指してこのような仕様にしています。

テストファイルはどのように扱われるか?

前述の通り、ts-remove-unusedに渡される tsconfig.json は必要十分なファイルをカバーしていることが求められています。そのため、ひとつの tsconfig.json でテストファイルと実装のファイルが両方カバーされている場合には、--skip の設定次第でテストファイルが全部削除されてしまいます。この場合も、--skip にテストファイルにマッチする正規表現を指定することで、ts-remove-unusedを使うことができます。

おすすめはTypeScriptのProject Referencesを使ってテストと実装の tsconfig.json を分割することです。tsconfig.json が実装のファイルのみをカバーしている場合、テストだけから参照されている定義は削除されます。ts-remove-unusedを実行後に tsc を実行すれば、不要になったテストコードもわかるはずです。

https://www.typescriptlang.org/docs/handbook/project-references.html

おわりに

TypeScriptを使っているとひとことでいっても、その程度は strict: false からはじまり、設定の厳重さ・堅牢さにはグラデーションがあると思います。いわゆるリンターよりも編集の条件が複雑なためどうしても前置きが長くなってしまいましたが、ts-remove-unusedはちゃんとTypeScriptを堅牢に設定しているひとにとって使いやすい、またメンテナンスされていないTypeScriptの設定を見直すことを促してくれるようなツールになったらと思っています。

プロジェクトを気に入っていただけたら、ぜひGitHubでStarをつけてください!励みになります!Contributionもお待ちしています。

https://github.com/line/ts-remove-unused

Discussion