TypeScript を遜色なく JavaScript にトランスパイルするための工夫

通常 TypeScript を JavaScript にトランスパイルする場合なるべく minify して難読化されてしまうが、どうしても JavaScript は難読化しないでほしいし、 **型特有の情報のみを取り除くだけで形は保持したい**という需要があったりする。
しかしそれができるライブラリが存在しなさそうなので、自分でやった工夫についてここで紹介する。

## 自分の場合の必要な理由

TypeScript で作業した方が楽なので TypeScript で書いていたが、コードを利用する側が TypeScript に不慣れで JavaScript で編集したいということで、 JavaScript の編集にも TypeScript に近い編集エクスペリエンスがあった方がよさそうだと考えたからである。

## 構成の仕方

私が遜色なくトランスパイルするために行った処理の流れはこのような感じ。

1. Babel を使って JavaScript へのトランスパイルを行う
2. 自作のスクリプトで正規表現を使って上記トランスパイルの結果を修正する。

構文解析などの処理をやるのは流石に面倒なので、そこは巷にあるトランスパイラである程度処理を行い、そこで生成されるコードにある問題点を修正するコードを入れた。
これでも意図したレイアウトが崩れてしまうことがあるが、不自由をきたさない程度ではある。

## トランスパイラの設定

Babel の設定ファイル (`Babel.config.json`) には次のように設定する。

```json
{
	"presets": [
		"@babel/preset-typescript"
	],
	"plugins": [
		[ "babel-plugin-add-import-extension", { "extension": "js" } ]
	]
}
```

`@babel/preset-typescript` により、 minify は行わず純粋にトランスパイルだけ実行される。
プラグインとして `babel-plugin-add-import-extension` を導入している。これは `import` 文においてファイル名の指定を調整する役割を持つ。

例えば TypeScript ファイル `foo.ts` で次のような `import` 文があるとする。

```typescript
// foo.ts

// `bar.ts` から `hoge`, `fuga` がインポートされる
import { hoge, fuga } from "./bar";
```

これをトランスパイラで `foo.js`, `bar.js` に変換すると次のように全く変わらない。

```javascript
// foo.js

import { hoge, fuga } from "./bar";
```

プラグインを導入すると次のように拡張子 `.js` が付加される。

```javascript
// foo.js

import { hoge, fuga } from "./bar.js";
```

これは特に HTML でこの TypeScript ソースファイルを使用する場合に必要となる処理だ。
HTML では次のように[モジュール形式](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Modules)の JavaScript ファイルをインポートできる。

```html
<script type="module" src="foo.js"></script>
```

ここでインポートされる JavaScript ファイル内で別のファイルの関数などを `import` 文でインポートする際に `"./bar"` のような拡張子がない形式の指定は認められておらず、実際のパスの通り `.js` が付いた形で指定しなくてはならない。このことに対応したプラグインである。

## 修正するスクリプトの内容

自作スクリプトでは以下の点を修正している。
これだけで考慮すべき問題を全て対処したとは言えず、何か抜けている処理があるかもしれない。

* 残っている幻の JSDoc を取り除く処理
* 2つ以上連続する空行を取り除く
* インデントルールを調整する (必要であれば)

### JSDoc を取り除く処理

TypeScript には `type` や `interface` といった型に関する構文がある。これらはトランスパイラによって消えるのだが、これらに付帯した JSDoc は残ってしまう。

#### 対象とするコード

例えば次のような TypeScript のコードがあったとする。

```TypeScript
// 適当な例

/** #RRGGBB のパターンにマッチする正規表現 */
const hexRe = /^#(?<red>[0-f]{1,2})(?<green>[0-f]{1,2})(?<blue>[0-f]{1,2})$/i;

/** #RRGGBB の形式の色指定に対応する型 */
interface Hex {
	/** 赤成分 */
	red: string;
	/** 緑成分 */
	green: string;
	/** 青成分 */
	blue: string;
}

/** #RRGGBB の形式の色指定から各成分を取り出す */
const decomposeHex = (strValue: string): Hex | null => {
	const matched = strValue.match(hexRe);
	if (matched == null) return null;
	return matched.groups! as Hex;
};
```

これをトランスパイラで JavaScript に変換すると次のようになる。

```JavaScript
// 適当な例

/** #RRGGBB のパターンにマッチする正規表現 */
const hexRe = /^#(?<red>[0-f]{1,2})(?<green>[0-f]{1,2})(?<blue>[0-f]{1,2})$/i;

/** #RRGGBB の形式の色指定に対応する型 */

/** #RRGGBB の形式の色指定から各成分を取り出す */
const decomposeHex = (strValue) => {
	const matched = strValue.match(hexRe);
	if (matched == null) return null;
	return matched.groups;
};
```

`interface` 構文が消えたが、それを説明していたはずの JSDoc である `/** #RRGGBB の形式の色指定に対応する型 */` は残っている。
こういった「何を指しているか分からない」 JSDoc を取り除くための処理を追加で行う必要がある。

また、 `type` 構文の場合は以下のようにトランスパイルでセミコロンだけ残ってしまうことがあるようだ。なので直後に残りうるセミコロンも確実に除去する。

```TypeScript
// トランスパイル前

/** `true` か `false` か `null` をとる */
type NullableBool = boolean | null;
```
```JavaScript
// トランスパイル後

/** `true` か `false` か `null` をとる */
;
```

#### 処理方法

以下は取り除く処理を行うコードの一例 (TypeScript) である。コード全体の文字列を受け取って、処理をして返す。
正規表現を使うにしても、該当の JSDoc をうまく検出するには逆順にせざるを得ないようなので、逆順にして処理している。

```TypeScript
const removeJsDocForEmptyStatement = (src: string): string => {
	// 文字列を逆順にする
	src = src.split("").reverse().join("");
	// 正規表現 `jsDocPattern` を使って除去する
	src = src.replace(jsDocPattern, "");
	// 文字列の順序を元に戻す
	src = src.split("").reverse().join("");

	return src;
};
```

正規表現 `jsDocPattern` は以下のように[構造化した正規表現の記載方法](https://qiita.com/smileyFace/items/668608810a3c2fa45d8d)に従って構築する。

```TypeScript
const jsDocPattern = RegExp(regexConcat([
	// JSDoc の後に続く可能性のある取り除くべき項目
	// 逆順なので JSDoc の本体よりも前に配置する
	regexOr([
		// JSDoc の後にセミコロン ; だけ残っている場合
		r`;\s*`,
		// JSDoc の直後に空行がある場合
		r`[ \t]*[\n\r\v]{2}`,
		// JSDoc の後に改行がいくつか続いて EOF に達する場合
		r`^\s*`
	]),
	// 取り除く対象の JSDoc の本体
	regexConcat([ r`/\*`, `.*?`, r`\*\*\/` ])
]), "gs");
```
```TypeScript
// `r`, `regexConcat`, `regexOr` の定義

const r = String.raw;

// 正規表現のパターンを結合し、グループにする
const regexConcat = (items: string[]): string => {
	return r`(?:` + items.join("") + `)`;
};
// 渡した正規表現のどれか1つにマッチする場合、という正規表現を構築する
const regexOr = (items: string[]): string => {
	return r`(?:` + items.join("|") + r`)`;
};
```

### 2つ以上の連続する空行を取り除く

トランスパイルで `type` や `interface` が取り除かれるとその前後にあった空行がまとまって複数行の空行が発生することがある。

#### 対象とするコード

次のような1つの内容ごとに空行がある場合である。

```TypeScript
const radians = (deg: number): number => {
	return deg / 180 * Math.PI;
};

type CosSin = [cos: number, sin: number];

const cos_sin = (rad: number): number => {
	return [ Math.cos(rad), Math.sin(rad) ];
};

// 単位円上の57度の位置の座標
const [x, y] = cos_sin(radians(57));
```

これがトランスパイルされると2つ以上の空行が発生する。

```JavaScript
const radians = (deg) => {
	return deg / 180 * Math.PI;
};



const cos_sin = (rad) => {
	return [ Math.cos(rad), Math.sin(rad) ];
};

// 単位円上の57度の位置の座標
const [x, y] = cos_sin(radians(57));
```

特に上記の不要な JSDoc を取り除く作業を行なった後だと発生しやすい。

#### 処理方法

以下は取り除くコードの一例である。2つ以上の空行が空くとは、3つ以上の改行が入ることを意味している。

```TypeScript
const removeMultipleLineBreaks = (src: string): string => {
	// 改行が3個以上連続する場合に2個に置き換える
	src = src.replace(/[\n\r\v]{3,}/g, "\n\n");
	// ファイル末尾の必要以上の改行は取り除く
	src = src.replace(/\s+$/,"\n");

	return src;
};
```

### インデントルールの修正

トランスパイラでは元の TypeScript がどのようなインデントルールを使っていたとしても、スペース2文字分に変換する。
元もスペース2文字なら問題ないが、それ以外だと困るので元に戻す処理が必要である。

#### 処理方法

```TypeScript
const changeIndentRule = (src: string): string => {
	while (indentReplPattern.test(src)) {
		src = src.replace(indentReplPattern, `$<keep>${destIndent}`);
	}
	return src;
};

// ここでは分かりやすくタブ文字に変換することを考える。

/** トランスパイラで使用される標準のインデントルール */
const defaultIndent = "  ";
/** 置換先のインデントルール */
const destIndent = "\t";
/** インデント変換の正規表現 */
const indentReplPattern = RegExp(regexConcat([
	r`^`,
	// 保持するインデント
	regexGroup(
		"keep",
		r`(?:` + destIndent + r`)*`
	),
	// 変換対象のインデント
	r`(?:` + defaultIndent + r`)`
]), "mg");
```
```TypeScript
// `regexGroup` を定義

/** 名前付きのキャプチャグループを構成する */
const regexGroup = (name: string, pattern: string): string => {
	return `(?<${name}>${pattern})`;
};
```

### その他

他にも気づいていないだけで、もっと修正を入れるべき不具合はあるかもしれないが、対処できてない。
もしこうした処理もした方がいいというアイデアがありましたら、教えてください。

## トランスパイラを用意

ここまでで示してきた、 Babel を使ったトランスパイルと自作スクリプトによる修正をまとめて行う機構を準備してみる。

`tree` の記法でディレクトリ内の構造を示すと大まかに次のようになっているとする。

```
.
├── package.json
├── clean_files.ts
├── ts_src
│   └── *.ts
├── js_src
│   └── *.js
└── tsconfig.json
```

`ts_src` 以下に含まれる TypeScript ファイルを JavaScript に変換して `js_src` に保存されるようにする。

### スクリプトファイルを用意

`clean_files.ts` には修正するスクリプトが含まれる。上記で示した各々の関数と合わせて、 JavaScript ファイルの読み書きの機能も含める。

```TypeScript
// 一応 Node.js で動作させることを前提とする
import {
	readdirSync,
	statSync,
	readFileSync, writeFileSync
} from "node:fs";
import { join as pathJoin } from "node:path";

/** 指定したディレクトリ内の JavaScript ファイル全てに対して処理を行う */
const cleanJsFiles = (targetDir: string) => {
	const entries = readdirSync(targetDir);

	// ディレクトリに入っている項目ごとに探査する
	for (const file of entries) {
		const path = pathJoin(targetDir, file);

		// ディレクトリの場合はその内側の JavaScript ファイルを探索する
		const stat = statSync(path);
		if (stat.isDirectory() && !stat.isSymbolicLink()) {
			cleanJsFiles(path);
			continue;
		}

		// JavaScript ファイルでない場合は無視
		if (!file.endsWith(".js")) continue;

		// ファイルを書き換える
		cleanJsFile(path);
	}
};

/** 指定した1つの JavaScript ファイルに対して処理を行う */
const cleanJsFile = (path: string) => {
	// ファイルを開き、内容を読み込む
	let content = readFileSync(path, "utf-8");

	// 処理を実行する
	content = removeJsDocForEmptyStatement(content);
	content = removeMultipleLineBreaks(content);
	content = changeIndentRule(content);

	// ファイルに上書きする
	writeFileSync(path, content, "utf-8");
};



/** JavaScript ファイルが保存されているディレクトリ */
const jsSrcDir = "js_src";

// `js_src` に対して処理を行う
cleanJsFiles(jsSrcDir);
```

### パッケージの構成

`package.json` でトランスパイラの設定や、必要なパッケージの設定を記す。
(今回のトランスパイラと関係のない部分は省略した)

```jsonc
{
	// npm install * の処理内容の指定
	"scripts": {
		// npm run build でトランスパイルできるようにする
		"build": "npm run build-step-babel && npm run build-step-clean-files",
		// それぞれのステップの処理
		"build-step-babel": "babel ts_src --out-dir js_src --extensions \".ts\"",
		"build-step-clean-files": "tsx scripts/clean_files.ts",
	},
	// 依存パッケージの指定
	// 動作確認済みのバージョンを記載している
	"devDependencies": {
		// Babel を使ったトランスパイルのためのパッケージ
		"@babel/cli": "^7.27.0",
		"@babel/core": "^7.26.10",
		"@babel/preset-typescript": "^7.27.0",
		"babel-plugin-add-import-extension": "^1.6.0",
		// TypeScript ファイルを実行するためのパッケージ
		"tsx": "^4.19.3"
	}
}
```

最新の Node.js では TypeScript が直接実行できるようになっているが、この対応が比較的最近であり Node.js のバージョンによっては動作しないことも想定して、 `tsx` を使って TypeScript を実行することにした。
TypeScript のランタイムとしては他に `ts-node` もあるが、おそらくこちらでも動作するはずだ。

以上の設定により、以下のコマンドでトランスパイルができるようになるはずだ。

```shell
# 初回だけパッケージのインストールのために実行する
npm install

npm run build
```