Bun ShellでTypeScript自動化スクリプトを作る — インストールから実践パターンまで

Bun ShellでTypeScript自動化スクリプトを作る — インストールから実践パターンまで

Bun 1.3.14で実際に実験したBun Shell完全ガイド。$テンプレートリテラル、.nothrow()エラー処理、Promise.all並列化、macOS echoの罠まで実行ログ付きでまとめた。zxとの実質的な違いやプロダクション配備時の注意点も解説。

シェルスクリプトを書くとき、ちょっとした悩みが生まれる。bashで書けば慣れているがWindowsで壊れる。Node.jsのchild_processはコールバックまみれになる。zxは追加パッケージが必要だ。そんな中でBun Shellを直接試してみた。最初は「zxの劣化版じゃないか」と思っていたが、実際に動かすと少し考えが変わった。

この記事はBun 1.3.14をローカルにインストールして直接実験した結果に基づいている。ドキュメントに書いてあることと実際に動くものが違う部分もあったので、それを正直に書いた。

Bun Shellとは何か、なぜ今使われているのか

BunはJavaScriptランタイムであり、パッケージマネージャー、バンドラー、テストランナーでもある。Node.jsが複数のツールに分散している生態系を一つにまとめようとするプロジェクトだ。PythonのuvがPipとpyenvとpoetryを統合するように、Bunはnpm/yarn/pnpm + テストランナー + バンドラーを一つに統合する。

Bun Shellはこの統合の延長線上にある。bunをインストールするだけで、別途設定なしに$テンプレートリテラルでシェルコマンドをTypeScript内で直接実行できる。

zxとの違い

正直、APIそのものは似ている。両方とも$`command`構文を使う。核心的な違いは一つ: Bun ShellはBashに依存しない。

zxは内部的にシステムのbash(またはsh)を呼び出す。WindowsでBashがなければWSLやGit Bashが必要になる。Bun ShellはRustで実装された自前のシェルを内蔵しているので、Bashなしでも動作する。lsrmechocdmkdirなどの命令がOSに関係なく同じように実行される。

チームにWindowsの開発者がいる場合、この違いは大きい。

インストール方法

Bunのインストールは一行で済む:

curl -fsSL https://bun.sh/install | bash

インストール後、シェル設定ファイル(~/.zshrcまたは~/.bashrc)にPATHが自動追加される。現在のセッションに適用するには:

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun --version  # 1.3.14

新しいプロジェクトの初期化:

mkdir my-scripts && cd my-scripts
bun init -y

bun initpackage.jsontsconfig.jsonindex.tsを自動生成する。TypeScriptを追加設定なしにすぐ実行できる点が便利だ。

基本パターン: $テンプレートリテラルでコマンドを実行する

最も基本的な使い方から始めよう。bunモジュールから$をインポートする:

import { $ } from "bun";

// コマンド実行
await $`echo "Hello from Bun Shell"`;

// 出力キャプチャ
const files = await $`ls -la`.text();
console.log(files);

// JavaScript変数の補間(自動エスケープ!)
const filename = "my file.txt";  // スペース含む
await $`echo "${filename}" > output.txt`;
// → output.txtに"my file.txt"が保存される(スペースが正しくエスケープ)

変数補間の自動エスケープは実際に動作を確認した。スペースを含むファイル名を渡しても別途処理なしに正しく扱われる。bashスクリプトで"${var}"でくくるのを忘れてハマる事故が減る。

出力形式メソッド

const result = await $`ls`;

// 文字列で
const text = await $`ls`.text();

// 行単位の配列で(Bun特有の便利メソッド)
const lines = await $`ls`.lines();
// → ["file1.ts", "file2.ts", ...]

// Blobで
const blob = await $`cat file.txt`.blob();

.lines()は出力を行単位の配列にパースしてくれる便利メソッドだ。text().split('\n')を自分で書くよりずっとすっきりする。

エラー処理、環境変数、パイプライン

エラー処理の二つのパターン

Bun Shellでコマンドが失敗すると(exit code != 0)、デフォルトで例外を投げる。

// パターン1: try/catch
try {
  await $`ls /nonexistent-dir`;
} catch (e) {
  console.log("エラー:", e.message); // "Failed with exit code 1"
}

// パターン2: .nothrow() — エラーをexitCodeで返す
const result = await $`ls /nonexistent-dir`.nothrow();
console.log(result.exitCode); // 1
console.log(result.stderr.toString()); // エラーメッセージ

実務では.nothrow()をよく使う。ファイルの存在確認、コマンドのインストール確認などにtry/catchより使いやすい:

const nodeResult = await $`node --version`.nothrow();
if (nodeResult.exitCode === 0) {
  console.log("Node.js:", nodeResult.stdout.toString().trim());
} else {
  console.log("Node.jsがインストールされていません");
}

実験した結果、このパターンは正常に動作した。

環境変数の設定

// グローバルデフォルト設定
$.env({ API_KEY: "secret123", PATH: process.env.PATH! });

// 単一コマンドにローカル適用
const result = await $`echo $LOCAL_VAR`
  .env({ LOCAL_VAR: "only this command", PATH: process.env.PATH! })
  .text();

注意: .env()を使う際は既存のPATHを明示的に渡す必要がある。渡し忘れるとPATHが空になり、lsなどの基本コマンドも実行できなくなる可能性がある。

パイプライン

// Bun Shell内蔵パイプ
const sorted = await $`printf "banana\napple\ncherry\n" | sort`.text();
// → apple\nbanana\ncherry

// 重複除去 + ソート
await Bun.write("input.txt", "banana\napple\ncherry\napple\n");
const unique = await $`sort < input.txt | uniq`.text();

ここで一つ罠がある。macOSでecho "banana\napple"と書くと\nが改行として解釈されない。bashのecho -eと違い、macOSのデフォルトのechoはエスケープシーケンスを処理しない。printfを使う必要がある。

Bun ShellはBashなしで動くが、OS組み込みコマンドの動作はそのOSに従うという点を頭に入れておこう。

並列実行:Promise.allが鍵だ

Bun Shellでコマンドを並列実行するにはPromise.allを使う。コマンドを順番に書くと順次実行になる。

// 順次実行 (~200ms)
await $`sleep 0.1`;
await $`sleep 0.1`;

// 並列実行 (~100ms)
await Promise.all([
  $`sleep 0.1`,
  $`sleep 0.1`,
]);

実際に計測したところ、順次実行は約471ms、並列実行は約263msだった。期待よりオーバーヘッドがあるのはmacOSでのプロセス生成コストのためだ。それでもIOが多い処理では並列化の効果がある。

実用的なビルドスクリプト例

Bun Shellの真価はビルドスクリプトに現れる。複数ファイルの生成・検証・移動をTypeScriptのロジックと混在させられる:

import { $ } from "bun";

const DIST = "./dist";
const SRC = "./src";

async function build() {
  // 1. クリーンビルド
  await $`rm -rf ${DIST} && mkdir -p ${DIST}`;

  // 2. TypeScriptファイル一覧
  const tsFiles = await $`ls ${SRC}/*.ts`.text();
  const files = tsFiles.trim().split("\n");

  console.log(`ビルド対象: ${files.length}ファイル`);

  // 3. 並列処理
  await Promise.all(
    files.map(async (f) => {
      const name = f.split("/").pop()!.replace(".ts", ".js");
      await $`bun build ${f} --outfile ${DIST}/${name}`;
    })
  );

  // 4. 結果確認
  const built = await $`ls ${DIST}/`.text();
  console.log("ビルド完了:", built.trim().replace(/\n/g, ", "));
}

build().catch(console.error);

このスクリプトをscripts/build.tsとして保存してbun run scripts/build.tsで実行する。Node.js + ts-nodeの組み合わせが不要になる点が体感として楽だ。このビルドスクリプトをGitHub ActionsのCI/CDパイプラインと連携するのも自然な次のステップだ。

実験で見つけた罠たち

正直に書く。

罠1: .stdin() APIが1.3.14で動かない

ドキュメントや例では$`command`.stdin("text")という形を見ることがある。1.3.14時点でこのAPIは存在しない。実行時にstdin is not a functionエラーが発生する。

代替策: ファイルを経由するか、パイプチェーンでprintfを使う。

// ❌ 動かない (1.3.14)
await $`sort | uniq`.stdin("banana\napple\ncherry");

// ✅ 代替1: ファイル使用
await Bun.write("/tmp/input.txt", "banana\napple\ncherry\n");
await $`sort < /tmp/input.txt | uniq`;

// ✅ 代替2: printfでパイプ
await $`printf "banana\napple\ncherry\n" | sort | uniq`;

罠2: グローバルの$.env()がPATHを上書きする

$.env()に渡すオブジェクトが既存の環境変数を完全に置換する。PATHを忘れると以降のすべてのコマンドで実行ファイルが見つからなくなる:

// ❌ 危険: PATHがなくなる
$.env({ MY_VAR: "value" });
await $`ls`;  // エラーになりうる

// ✅ 安全: PATHを明示的に含める
$.env({ MY_VAR: "value", PATH: process.env.PATH! });

罠3: macOSのechoは\nを解釈しない

前述の通り、Bun ShellはBashを使わないのでmacOSのデフォルトのechoが使われる。Linux bashではecho "a\nb"が改行付きで出力されるが、macOSではa\nbがそのまま出力される。

// ❌ macOSで期待通りに動かない
await $`echo "apple\nbanana\ncherry" | sort`;
// → 一行で "apple\nbanana\ncherry" と出力

// ✅ printfを使う
await $`printf "apple\nbanana\ncherry\n" | sort`;
// → apple, banana, cherry (各行で)

クロスプラットフォームを謳っているが、組み込みコマンドの動作差異はOSに従うことを覚えておこう。

いつBun Shellを使い、いつ使わないべきか

私の結論を言うと: すでにBunを使っているプロジェクトならBun Shellを使う理由は十分ある。そうでなければzxから始める方が現実的だ。

Bun Shellを使うべきとき

  • プロジェクトがすでにBunベース: パッケージマネージャーとしてbunを使っているなら追加依存なしでシェルスクリプトが使える。
  • チームにWindowsの開発者がいる: Bashなしで動くクロスプラットフォームシェルが必要なとき。
  • ビルド/デプロイスクリプトをTypeScriptで統合したいとき: 設定コードとシェル操作を同じファイルで処理。

Bun Shellを使わなくていいとき

  • プロジェクトがNode.js + npmベースでマイグレーション計画がない。
  • 複雑なbashスクリプトが既にあり、Bun Shellのbash互換性が不確実。
  • zxが既によく動いていてチームが慣れている。

Bun Shellがzxより「優れている」という主張には同意しない。エコシステムの成熟度とダウンロード数でzxが上だ。Bun Shellは「Bunを使う人には自然な選択」であって「すべてのプロジェクトでzxの代わりに使うべき」ということではない。

個人的には、.stdin() APIがまだ安定していない点が残念だ。これが安定したらstdinベースのパイプ処理がずっとすっきりするはずなのだが。

結局、いま使う価値はあるか

実際にインストールして動かしてみて感じたのは、Bun Shellの開発者体験が思ったより良いということだ。変数の自動エスケープ、.nothrow()パターン、.lines()などの便利メソッドはzxにも見られないディテールだ。

ただし、まだ1.xバージョンでAPIが安定していない部分がある。プロダクションのCI/CDスクリプトに使う前に実際の環境で十分に検証することを勧める。Claude Codeフックのような自動化パイプラインに統合する際も同様だ。

Bunが進化し続ける中でShell APIも安定していくだろうと思う。今すぐzxを捨てる必要はないが、新しいBunプロジェクトなら内蔵シェルを先に試してみる価値はある。


実験環境:

  • Bun: 1.3.14 (macOS arm64)
  • サンドボックス: /tmp/bun-lab-final/
  • 実験日: 2026-05-25

よくある質問

Bun Shellとzxの核心的な違いは何ですか?
API構文はほぼ同じですが、Bun ShellはBashに依存しません。zxはシステムのbashやshを呼び出すためWindowsではWSLやGit Bashが必要ですが、Bun ShellはRustで実装した自前のシェルを内蔵し、ls、rm、echoなどのコマンドをOSに関係なく同じように実行します。
Bun 1.3.14で.stdin()に文字列を直接渡せますか?
いいえ。1.3.14で文字列を直接渡すとstdin is not a functionエラーが発生します。最も安定した代替策は、Bun.writeでファイルに書き出してリダイレクトするか、printfでパイプする方法です。
$.env()を使うとき、なぜPATHを明示的に渡す必要があるのですか?
$.env()に渡すオブジェクトが既存の環境変数をマージせず完全に置換するためです。PATHを忘れると以降のすべてのコマンドでlsなどの基本実行ファイルが見つからなくなるので、process.env.PATHを明示的に含める必要があります。
今すぐzxの代わりにBun Shellを使うべきですか?
プロジェクトがすでにBunベースか、チームにWindowsの開発者がいる場合は追加依存なしで使えるため良い選択です。ただしNode.js + npmベースで移行計画がない、またはzxが問題なく動いているならzxを維持する方が現実的です。

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください。

著者について

jw

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。

ブログリストへ