🧹

TypeScript Remove (tsr)でTypeScriptプロジェクトの大掃除をしませんか?

に公開

TL;DR

大規模なプロジェクトで長期間開発を継続していると、リファクタリングが行われたり、大きい機能追加が行われたりを繰り返すことによってコードベースが大きくなります。大きくなったコードベースには多くの場合デッドコードが存在しますが、これは不要なメンテナンスコストを発生させたり可読性に悪影響を及ぼしたりします。この年末、TypeScript Remove (tsr) を使ってコードベースから不要なコードを自動で削除する大掃除をしませんか、という話です。

https://github.com/line/tsr

問題

多くの場合、プロジェクトのコードはあるファイル (= entrypoint) を起点に実行されます。例えば、src/main.ts というファイルを起点に src/utils.ts というファイルを呼び出しているプロジェクトについて考えます。

src/main.ts
import { f } from './utils.js';

f();
src/utils.ts
export function f() {
  console.log('f');
}

// not used
export function g() {
  console.log('g')
}

2つのファイルしか存在しないこのプロジェクトの場合には、g() が使われていないことは明白です。しかし、実際には大規模なプロジェクトになればなるほど依存関係のグラフは複雑になります。単純に export がすべてのファイルから使われていないだけならVSCodeのTypeScript Integrationを使って確認したい export ごとに参照箇所を洗い出して削除することができます。これは大変な作業ですが本当の問題はentrypointから辿ることができない依存グラフがプロジェクト内で構成されている場合です。順を追って依存関係を辿らないと本当に使われていないことを確かめることができません。このような場合、手動でデッドコードを削除するメンテナンスを行うのは極めて困難です。

Viteなどのバンドラを使用する際には、静的解析によってtree-shakingが行われます。これはimport文を静的解析することによって、entrypointから到達することができないモジュールをバンドルに含めないというものです。

バンドルにデッドコードを含めないための方法はバンドラととも発達しているものの、src/ などの配下にあるソースコードの中からデッドコードを洗い出し、メンテナンスを行うためのツールは未だ発展途上です。

TypeScript Remove (tsr)

TypeScript Remove (tsr) はこの問題を解決します。従来よりts-remove-unusedとして開発していましたが、 v1.0.0 をリリースするにあたって名前をリニューアルしました。以前の状態 から v1.0.0 をリリースするまでに、いくつかの変更を行なっています。

  • 内部の設計の変更: ts.LanguageService.findReferencesを使用する実装から、インポート文をトラバースして参照箇所を洗い出す独自の実装に切り替えました。これによってts.LanguageService.findReferences APIの仕様による制約を回避できただけでなく、ワークアラウンドを必要としない安定性とパフォーマンスの向上につながりました。
  • 高速化: 以前は数十秒かかっていた処理が、vuejs/core のような大規模なリポジトリに対しても1秒以下に処理が完了するように大幅に高速化しました
  • CLIのアップデート: インターフェースに荒削りな部分が残っていましたが、v1.0.0tsr にリニューアルするタイミングでより洗練された形にアップデートしました。

TypeScript Remove (tsr) を使うことによって、プロジェクトに存在するデッドコードを検出して、自動で修正することが可能です。以降のセクションではサンプルとして vuejs/core リポジトリを例に説明します。[1]

環境構築

はじめに vuejs/core リポジトリをセットアップします。

git clone --depth=1 -b v3.5.13 git@github.com:vuejs/core.git vue_core
cd vue_core

不要なコードを検出する

TypeScript Remove (tsr) を使用するためには、処理の起点となるファイルを把握する必要があります。処理の起点となるファイルがなければ、すべてのモジュールは不要になるためです。

vuejs/core の場合はmonorepo構成を採用しており、以下のようなファイルが処理の起点になっていました。

  • src/**/index.ts
  • src/**/runtime.ts
  • src/**/cli.ts

たとえば compiler-core パッケージの場合には src/packages/compiler-core/index.ts というファイルがentrypointになっています。

tsrを実行する際には引数としてentrypointに合致する正規表現を渡します。vuejs/core の場合は、entrypointに合致する正規表現を使用して、以下のように実行しました。

npx tsr -p tsconfig.build.json '.+/src/.*(index|runtime|cli)\.ts$'

tsrの実行結果

実行すると、不要なexportとファイルごと不要なファイル(モジュール)を確認することができます。

不要なコードを削除する

-w もしくは --write オプションを使うと、検出されたファイルを自動で編集することができます。

npx tsr -p tsconfig.build.json -w '.+/src/.*(index|runtime|cli)\.ts$'

tsrの実行結果

この状態でtype-checkを実行してもエラーは発生しません。tsrは安全な形でファイル編集を行います。[2]

npx tsc -p tsconfig.build.json --noEmit
## no errors

tsrが行った変更を見てみる

上記のコマンドの結果として編集されたファイルをいくつかピックアップしてみました。

diff --git a/packages/shared/src/globalsAllowList.ts b/packages/shared/src/globalsAllowList.ts
index 3b584cc..81543b3 100644
--- a/packages/shared/src/globalsAllowList.ts
+++ b/packages/shared/src/globalsAllowList.ts
@@ -7,6 +7,3 @@ const GLOBALS_ALLOWED =

 export const isGloballyAllowed: (key: string) => boolean =
   /*@__PURE__*/ makeMap(GLOBALS_ALLOWED)
-
-/** @deprecated use `isGloballyAllowed` instead */
-export const isGloballyWhitelisted: (key: string) => boolean = isGloballyAllowed
diff --git a/packages/compiler-ssr/src/errors.ts b/packages/compiler-ssr/src/errors.ts
index e4fd505..0e22233 100644
--- a/packages/compiler-ssr/src/errors.ts
+++ b/packages/compiler-ssr/src/errors.ts
@@ -5,7 +5,7 @@ import {
   createCompilerError,
 } from '@vue/compiler-dom'

-export interface SSRCompilerError extends CompilerError {
+interface SSRCompilerError extends CompilerError {
   code: SSRErrorCodes
 }

@@ -35,7 +35,7 @@ if (__TEST__) {
   }
 }

-export const SSRErrorMessages: { [code: number]: string } = {
+const SSRErrorMessages: { [code: number]: string } = {
   [SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME]: `Unsafe attribute name for SSR.`,
   [SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET]: `Missing the 'to' prop on teleport element.`,
   [SSRErrorCodes.X_SSR_INVALID_AST_NODE]: `Invalid AST node during SSR transform.`,

exportされている定義がファイル内で使用されているかによって、定義全体を削除するか、export キーワードだけを削除するか区別していることがわかります。例には表れませんでしたが、コードの編集によって追加で不要になった定義やimport文がある場合には、これも自動で編集されます。

おわりに

TypeScript Remove (tsr) によって、プロジェクトのデッドコードを削除するプロセスは簡単になります。この年末、プロジェクトのデッドコードの削除にチャレンジしてみるのはいかがでしょうか?

https://github.com/line/tsr

脚注
  1. Vue.jsのリポジトリを例に説明しますが、残念なことにtsrはSFCを扱うことはできません。vuejs/core は純粋なTypeScriptが使用されている大規模なプロジェクトとしてちょうどよかったので例として採用しました。 ↩︎

  2. tsrはentrypoint以外のモジュールに副作用がないことを想定しています。importされることに意味がある(=副作用)がある場合には、コードを削除することによって意図しない問題が生じる可能性があります。特にReactなどでコンポーネントの開発をしている場合など、一般的にはモジュールに副作用がないことが多いですが、マージする前にtsrが行なった変更をレビューするのが安全だと思います。 ↩︎

Discussion