サクっと単一の TypeScript ファイルで CLI 上で動作するツールを書く方法

本記事では、単一の TypeScript ファイルを使って CLI 上で動作するツールを作る方法を説明する。

## きっかけ

私の場合、ローカルにあるファイルを自動的に処理したり、普段の作業を自動化するために CLI 上で動作する軽いコードを作ったりすることがある。
そういうケースでガチガチに作りたい場合は C やら Rust やらを使って作ることもあるのだが、大した内容でない場合とか、使い捨てのコードにしたい場合とかはスクリプト言語で作ってしまいたい。

これまではどこでも動作するし、 (エディタでの作業環境整備も含め) 環境構築とかも大したことはしなくても良いという理由で、 Python で書くことが多かったが、いくつか不満があった。

* Python の型付けに満足できていなかった
	* これは単純に TypeScript や Rust の型システムを触り過ぎて、緩い型システムが辛くなっただけかもしれない
	* 指定した型通りでない値も入れてもエラーにはならないし
	* 複雑な型指定とかが扱えるようでもない
* データ構造も扱い
	* TypeScript だとフィールドの形式を明示した型が用意できるが、 Python にはできない
	* `@dataclass` を使えば `class` で似たようなことができるようだが、正直面倒
* ただ単純に Python の文法が好きでない
	* 個人差があります

もちろん NumPy や pandas を使う必要性があれば Python を使うしかないが、そういう場合を除けば TypeScript を使ってみたいという考えがあった。

TypeScript を使うにあたって1番の問題点は、 NPM プロジェクトを作る必要があることであった。 Python や JavaScript だと単一ファイルだけでも、実行ファイルとして動作させることができる。一方 TypeScript だと、まず `npm init` して、その中で `npm install typescript @type/node` して... などのセットアップを行うという巷の説明が多く、ちょっと大掛かりに感じていたため敬遠していた。

だけどよく考えたら意外と簡単にできる方法があったので、ここにまとめる。

## 結論

単一ファイルで動作する TypeScript コードを用意するには **Deno を使う**のが最適であることが分かった。

[Deno](https://deno.run) の場合、直接 TypeScript ファイルを実行することができ、 NPM プロジェクト内に `.ts` ファイルを配置する必要がなく、依存ライブラリがあっても JSR 経由でインポート可能なので、純粋に単一ファイルだけで完成させることができる。
また、 Visual Studio Code 向けには[拡張機能](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno)も用意されており、インストールすることでホバーすれば型の情報も表示される。

他にも TypeScript を実行できる方法はあるが、問題点があるので今回は見送った。詳しい内容は後述する。

## ファイルの作り方

予めシステムに Deno はインストールされているものとする。

適当な場所に `.ts` 拡張子の付いたファイルを用意し、 TypeScript でコードを書く。
以下には簡単な例として、 `mv` コマンドっぽいものを自作した場合の実装例を挙げている。 (エラーハンドリングもほぼやっていない)

```typescript
#!/usr/bin/env -S deno run --allow-read --allow-write

import { move } from "jsr:@std/fs/move";

/** 標準エラーにメッセージを出力します */
const echoToStderr = (msg: string): Promise<number> => {
	const encoder = new TextEncoder();
	const encodedMsg = encoder.encode(msg + "\n");
	return Deno.stderr.write(encodedMsg);
};

// 引数が2つでない場合は終了
if (Deno.args.length != 2) {
	await echoToStderr("usage: move.ts /path/to/src/file /path/to/dst/file");
}

// 引数の情報を収集する
type Info = { src: string; dst: string; };
const info: Info = { src: Deno.args[0], dst: Deno.args[1] };

// ファイルの移動を行う
await move(info.src, info.dst);

echoToStderr("moved successfully");
```

* 1行目のように `#!` で始まる Shebang を付けることができ、実行パーミッションだけ与えておけば `./move.ts` とするだけで実行できる。
	* Shebang 中に `--allow-read`, `--allow-write` などの Deno 実行時に必要なパーミッションも設定している
* 依存パッケージの管理を `package.json` でしない代わりに JSR からの `import` 文を書いており、実行時に該当パッケージをフェッチして使用する。

Node.js ではないので Node.js の標準ライブラリは使用できないかもしれないが、 Deno に同様の機能が標準ライブラリで用意されていることが多い。どうしても Node.js の標準ライブラリでないといけない場合を除けばこれでも十分だと思われる。

## Visual Studio Code の環境整備

Visual Studio Code の標準機能で NPM プロジェクトであれば依存パッケージの型情報とかを参照できる機能が用意されているが、 Deno の標準ライブラリや JSR パッケージの型情報は標準では教えてくれない。なので [Deno の拡張機能](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno)をインストールする。

拡張機能をインストールした後は `settings.json` で次の設定を行う。

```json
{
	"deno.enable": true
}
```

通常は `import` 文で依存する NPM パッケージ内からシンボルを検索し、見つからなかったらエラーを発するが、この設定をオンにすると、エラーを上書きして Deno としてシンボルがあるか否かを判定するようになる。
逆に言えば、 `true` に設定することで NPM プロジェクト内にある `.ts` ファイルでも Deno としてシンボルを探してくるため、エラーになってしまう。

Deno の拡張機能の説明には、グローバルな設定で `"deno.enable": true` としないでほしいと書かれている。これはおそらく上記のことが原因だと考えられる。しかし、単一の TypeScript ファイルがシステム内のどこに保存されるかわからないし、 VSCode はワークスペース単位で個別に設定を無効にできるので、 NPM プロジェクトで Deno が機能して欲しくない場合は個別に設定を切れば良いことから、グローバルに `"deno.enable": true` としても問題なさそうである。

## 他の方法の検討

TypeScript を直接実行できる方法としては他にも `tsx` や `ts-node`, Bun などがある。

[`tsx`](https://tsx.is) や [`ts-node`](https://typestrong.org/ts-node/) は Node.js をベースにしており、直接 TypeScript ファイルを実行したり、 REPL で TypeScript を使用することができる。標準ライブラリも Node.js のものが使用できる。
[Bun](https://bun.sh) は Deno と同様に Node.js とは異なるランタイムとツールキットを提供し、 TypeScript ファイルが直接実行できる。標準ライブラリは Deno や Node.js のものとは異なる。

### ファイルの作り方

3つのどの方法でも Deno と同じように Shebang を使って使用するランタイムを選択できる。

* `tsx`

	```typescript
	#! /usr/bin/env -S tsx
	import { writeFile } from "node:fs";
	...
	```

- `ts-node`

	```typescript
	#! /usr/bin/env -S ts-node
	import { writeFile } from "node:fs";
	...
	```

* Bun

	```typescript
	#! /usr/bin/env -S bun run
	import { write } from "bun";
	...
	```

### Visual Studio Code の環境整備

これらの方法が Deno に比べてオススメできない理由は環境整備にある。

上記のコードはそれぞれ `tsx`, `ts-node`, `bun` がグローバルインストールされていれば問題なく動作する。
```shell
npm install -g tsx
npm install -g ts-node
npm install -g bun
```

合わせてエディタ補完ができるようにするために `@types/node` や `@types/bun` をインストールしたいのだが、グローバルインストールしても補完されないため、結局同一ディレクトリ上でこれらをインストールするしかない。
```shell
npm install @types/node @types/bun
```

なので `.ts` ファイルに隣接して `package.json` や `package-lock.json`, `node_modules` が発生するため、単一ファイルで動作するという目的が達成できなくなってしまう。

[Bun にも Visual Studio Code 向けの拡張機能](https://marketplace.visualstudio.com/items?itemName=oven.bun-vscode)は用意されているが、これだけでは単一ファイルの TypeScript で型情報が表示されないっぽい。

### `ts-node` の不具合

あと `ts-node` の場合は、実行時に次のようなエラーが発生することがある。

```
TypeError: Unknown file extension ".ts" for /path/to/my_script.ts
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:219:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:245:36)
    at defaultLoad (node:internal/modules/esm/load:120:22)
    at async ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:483:32)
    at async ModuleJob._link (node:internal/modules/esm/module_job:115:19) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
```

`--esm` オプションを付けて `ts-node` を実行すればいいなどと書かれていたりもするが、オプションを付けて実行してもこのようなエラーが発生しうるので、根本的な解決策にはなっていない。

### Deno との併存

`ts-node`, `tsx`, Bun は NPM パッケージを使用するので、 `"deno.enable": true` だとエラー表示になる。つまり、 Deno を使う TypeScript ファイルとの併存は難しそうである。
(ただ上記の `import { ... } from "node/fs";` のような Node.js 標準ライブラリの `import` 文は Deno を有効にした状態でも問題なく認識されるようである。)

## 問題点

先ほど述べたように Python の特定のパッケージが必要な場合とかは TypeScript で走らせることはできないのでこの方法が使えない。

また、作ったプログラムを他所でも動かす必要がある場合も TypeScript で作るのはおすすめできない。 Deno がそこかしこにはインストールされていないからだ。ポータビリティを考えるのであれば Bash や Python なら昨今色々なところにインストールされているから、そっちを使ったほうが良さそう。 (それでもインストールされている Python のバージョンの違いは考えないと)

## 追記

Node.js では TypeScript の標準サポートするために活動されている模様。
ただ名前空間や列挙型がサポートされていないなどの制約がありそうで...
今後どうなるか、注目していきたい
(この記事の内容に関連してそうなので取り上げた)

https://github.com/nodejs/node/pull/56350

## まとめ

お手軽な TypeScript プログラムを走らせるには現時点では Deno を使うのが最も適していると考えられる。
これを機に必要に応じてちゃんと型も意識したいというケースにも対応できる TypeScript で手軽にコードを書いていこう。