12k
All articles

scriptc で TypeScript をネイティブバイナリへコンパイルする

scriptcはTypeScriptをネイティブバイナリに変換し、静的ビルド、620KBの動的エンジン、coverage確認、明確な診断を提供します。

OpenReplay Team
OpenReplay Team
scriptc で TypeScript をネイティブバイナリへコンパイルする

scriptc は、通常の TypeScript を自己完結型のネイティブ実行ファイルへコンパイルします。静的ビルドには JavaScript エンジンが同梱されません。例外は正規表現インタプリタで、これはコードが正規表現を使用する場合にのみリンクされます。型チェックは本物の TypeScript コンパイラが担当し、scriptc がそれを型付き中間表現へと下位変換し、最終的にネイティブコードが出力されます。

TypeScript で書いた CLI を配布したことがあるなら、あのトレードオフはご存じでしょう。ツール本体のロジックは 40KB。しかし配布手段は 100MB のランタイムとインストール手順、そしてユーザーが体感できるほどの起動コストです。

興味深いのはバイナリそのものではありません。他のツールもランタイムを内部に詰め込むことでバイナリを生成します。scriptc は可能な限りエンジンを省き、さらにプログラムのどの部分を扱えて、どの部分を扱えないかを明示します。本稿では、あらゆる構文が到達しうる 3 つの結果と、自分のコードがどこに分類されるかを教えてくれる 1 つのコマンドを解説します。

要点

  • scriptc は、あなたがすでに書いている TypeScript をそのままコンパイルします。習得すべき方言も、付与すべきアノテーションも、置き換え用の標準ライブラリも存在せず、型チェックは本物の TypeScript コンパイラを通して実行されます。
  • 静的コンパイルがデフォルトであり、--dynamic を渡さない限り唯一のモードです。--dynamic を指定すると、約 620KB の quickjs-ng がバイナリに埋め込まれます。
  • どちらの層にも収まらないものはビルドを停止させます。微妙に誤ったバイナリが出力される代わりに、SC コード、該当行、そして多くの場合は書き換えの提案が得られます。
  • scriptc coverage を実行すると、ステートメント単位の判定が得られます。どの部分が静的層に入るか、どの部分がエンジンを引き込むか、そして各ブロッカーを特定するコード付き診断が確認できます。
  • 大半の npm パッケージはプレーンな JavaScript と別ファイルの宣言ファイルを配布しており、静的層には型付きソースが存在しません。そのため、現実の依存ツリーはバイナリに埋め込みエンジンを呼び戻すことになります。

scriptc とは何か、パイプラインはどう動くのか

scriptc は .ts のエントリポイントを受け取り、TypeScript コンパイラで型チェックを行い、チェック済みプログラムを型付き IR へ下位変換し、そこからネイティブコードを出力します。scriptc README では LLVM がデフォルトのコードジェネレータとされ、C は --backend c で選択できる恒久的な可読リファレンスバックエンドとして維持されています。つまり「TypeScript から C、そして clang へ」という説明は、2 つある経路の片方を指しているにすぎません。投入するソースは、Node 上ですでに実行しているソースそのものです。

インストールはグローバルな npm install で、実行ファイルのビルドにはホスト側にリンカドライバが必要です。

npm install -g scriptc

Quickstart では、コンパイラの動作環境を Node 24 以降としています。実行ファイルのビルドにはさらにプラットフォーム用リンカと対応する SDK または sysroot が必要で、Platform Support が残りの条件を具体的に示しています。サポート対象の macOS、Linux、Windows ホストでは、LLVM 層がプリコンパイル済みのランタイムパックをリンクするため、C コンパイラが必要になるのは明示的な C ビルド、LLVM フォールバック、そして --sanitize の場合のみです。--emit=ir|c|llvm で選択するソース出力には Node 以外は不要です。

最小限のプログラムと、重要な 2 つのコマンドは次のとおりです。

// slug.ts
function slug(title: string): string {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
console.log(slug("Compile TypeScript to a Native Binary"));
scriptc run slug.ts
scriptc build slug.ts -o slug && ./slug

scriptc run はコンパイルと実行を 1 ステップで行うため、ウォッチループではこちらが適しています。scriptc build -o は、実際に配布する成果物を生成します。

Tier 1: 静的コンパイル(デフォルト)

静的コンパイルは scriptc のデフォルトであり、明示的にオプトアウトしない限り唯一のモードです。scriptc のホームページでは、tier 1 は日常的な TypeScript として紹介されています。クラスとクロージャ、async/await、標準ライブラリ、そして大半のプログラムが利用する Node の各機能(fs、path、process、http など)が含まれます。これらはすべてネイティブコードに変換され、バイナリにエンジンは含まれません。

実際にサポートされる範囲は、見出しのリストから想像されるよりも広範です。introduction ページでは、これを 3 つのグループに分類しています。言語面では、動的ディスパッチを伴う単一継承クラス、JavaScript と同じ方式でキャプチャするクロージャ、モノモーフィゼーションで解決されるジェネリック関数宣言、TypeScript 自身の絞り込みによって処理される判別可能ユニオン、JavaScript と完全に同じスケジューリングの async/await、finally を伴う例外、分割代入、スプレッド、アクセサ、イテレータ、テンプレートリテラルが利用できます。標準ライブラリのグループは、文字列、配列、Map と Set、JSON、Math、型付き配列、そして Error 階層をカバーします。Node のグループは fs(同期版と Promise 版の両方)に加え、path、process、child_process、os、crypto、url/URL、zlib、timers に及び、さらにサーバースタック全体(net、http、https、tls、dgram、dns、readline)を含みます。

つまり、純粋な関数だけでなく HTTP サービスもコンパイルできます。

// server.ts
import http from "node:http";

http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ path: req.url }));
}).listen(3000);
scriptc build server.ts -o server && ./server

静的サポート範囲の列挙は、コンパイラの進化とともに陳腐化します。changelog がリリースごとに追跡しており、各リリースには機械可読な surface-manifest.json も同梱されます。これはそのバージョンで静的層が扱う言語および標準ライブラリのサポート範囲を列挙したもので、エントリごとに安定した id が付与されているため、ツールで 2 つのリリースを差分比較できます。このファイルは、本稿を含むあらゆる散文のリストよりも長く有効であり続けます。

Tier 2: 動的層と 620KB のエンジン

--dynamic を渡すと JavaScript エンジンがバイナリに埋め込まれます。それ以外の手段で埋め込まれることはありません。npm dependencies ガイドでは、その結果を dynamic island(動的アイランド)と呼んでいます。約 620KB の埋め込みエンジンが、静的化できないものすべてを実行します。実務上は、npm パッケージが配布する JavaScript と、チェッカーが any と型付けするものすべてを指します。値は静的コード側へ戻る際に検証されます。エンジンは quickjs-ng です。

npm install picocolors
scriptc build cli.ts --dynamic -o cli

設計上の要点はオプトインである点です。scriptc のバイナリが黙ってエンジンを抱え込むことはなく、620KB は常に自分で要求した結果です。ここから 2 つの帰結が導かれます。パッケージの JavaScript はビルド時に実行ファイルへ取り込まれるため、完成したバイナリは自己完結しており、実行時に node_modules を参照する理由がありません。そして境界は信頼ではなく検証されます。宣言ファイルが string を約束しながらオブジェクトを返した場合、それを前提としたネイティブコードでメモリを破壊するのではなく、キャッチ可能な TypeError がスローされます。

Tier 3: コンパイル時に拒否

scriptc が静的にコンパイルできず、かつ動的層へ回すこともできないコードは、ビルドを失敗させます。この層についてホームページが約束しているのは、失敗が読み取り可能であることです。具体的なエラーコード、該当行、そして多くの場合は書き換え方法のヒントが示されます。「ほぼ同等」の何かに黙って置き換えられることはありません。診断コードには SC プレフィックスが付き、WASI ターゲットで遭遇するのが SC3002 です。ソケットと fetch、子プロセス、シグナル API、fs.watch はいずれもリンク段階の前にビルドを停止させます。Preview 1 ではゲストがそれらを実行する手段が一切提供されていないためです。

この 3 分岐こそが、残りの設計を真剣に受け止める価値がある理由です。構文を黙って「ほぼ同等」の何かに劣化させるコンパイラであれば、パフォーマンスとセマンティクスに関するあらゆる主張が条件付きになってしまいます。行番号と書き換え案を添えて出力を拒否することが、静的層の約束を検証可能にしているのです。

scriptc coverage は、自分のコードが適合するかをどう教えてくれるのか

scriptc coverage は、何も移行せずに「自分のコードはコンパイルできるか」に答えるための手段です。プログラムをステートメント単位で走査し、どれが静的層に入るか、どれがエンジンを必要とするか、そして残りを妨げているものは何かを、ブロック箇所ごとに診断コードを添えて報告します。おもちゃのファイルではなく、実際のエントリポイントに対して実行してください。

Quickstart では、2 ステートメントの hello.ts をこのコマンドに通しています。結果は「解析対象 2 ステートメント、静的コンパイル 2、100%」と、プログラムに動的な残余がないことを示す判定行です。README の例は実際のプロジェクトを対象としており、4481 ステートメント中 4451 が静的、すなわち 99% と報告されます。現実的なプロジェクトでは、これより低い比率と名前付きの該当箇所のリストが出力されます。読み方は 3 段階です。まず見出しの比率が、そのプロジェクトがそもそも候補になりうるかを示します。次に箇所ごとの診断が、何が妨げになっているかを示します。そして各ブロッカーの正体が、どの対処が適用できるかを示します。

ブロッカーはきれいに 2 種類に分かれます。型なしの npm import は書き換える対象ではなく、受け入れる対象であり、--dynamic でビルドすることを意味します。一方、自分のコード内の緩い型は、たいてい修正可能です。

// forces the dynamic tier: the payload is any
function port(config: any): number {
  return config.port + 1;
}

形状を宣言すれば、同じ関数が静的にコンパイルされます。

interface Config { port: number }

function port(config: Config): number {
  return config.port + 1;
}

型エラーや import フェンスによって解析が早期に停止する場合について、changelog には、coverage が単なる要約行ではなく、ビルド失敗時と同じ診断をコードフレームごと出力するようになったと記録されています。コマンドに --dynamic を加えると、さらに踏み込んで、埋め込みエンジンが実際に実行することになる箇所を教えてくれます。

プロジェクトが公表している数値は何か

ホームページでは、hello-world のバイナリを約 320KB、起動時間を約 4ms、リンクされるライブラリは libSystem のみとしています。対して Node ランタイムは約 120MB で、同じ 1 行を出力するのに約 35ms を要します。README のベンチマーク表は同一のワークロードについてより楽観的で、170〜200KB、起動約 2.4ms、Node は約 47ms としています。この 2 つのプロジェクト内ソースは一致していないため、ある数値がどちらに由来するのかを把握しておく価値があります。いずれにせよ、これらはファーストクラスホストである macOS 上の hello-world に関するプロジェクト自身の数値であり、あなたのアプリケーションに関する一般的な主張ではありません。

これらは予測値ではなく下限として扱ってください。--dynamic でビルドしたバイナリはエンジンと埋め込みパッケージの JavaScript を抱えるため、サイズの階級が変わります。自分の見積もりにそのまま転用できる数値は 620KB のエンジンコストです。これは固定かつ文書化された追加分であり、受け入れるか回避するかのいずれかだからです。

導入の実際のコストは何か

scriptc は vercel-labs 名前空間の下にあり、まだ 0.1.x です。2026 年 7 月下旬のリリース以降のコミュニティでの議論は、まさにその位置づけに集中しています。すなわち、ビルドパイプラインに組み込むコンパイラが要求する年単位のメンテナンスを、Labs プロジェクトが積み重ねられるのかという点です。リポジトリはタグ付きの npm リリースと Apache-2.0 ライセンスを公開していますが、サポートや SLA に関する表明は伴っていません。

より切実な実務上の制約はエコシステムです。大半の npm パッケージは、コンパイル済み JavaScript と別ファイルの .d.ts 宣言を配布しており、静的層にはコンパイルすべき型付きソースが存在しません。そのため、そのコードは --dynamic 下で埋め込みエンジン上で実行され、エンジンがバイナリに同梱されます。宣言が一切ないパッケージは黙って劣化するのではなく、TypeScript 標準の「宣言が見つからない」エラーで型チェックのゲートを失敗させます。これは厳格な TypeScript プロジェクトであればどこでも起こることと同じです。その他の粗い部分は、scriptc run が追加の CLI 引数をプログラムへ転送しないといった細部に至るまで個別に文書化されており、移行を計画する前に読む価値があるのが limitations ページです。

適合性を率直にまとめると、厳密に型付けされた CLI や、実行時依存がほとんどない小規模サービスは有力な候補です。一方、依存ツリーが深いプロジェクトは、コードの大部分のために 620KB のエンジンと埋め込み JavaScript を買うことになります。CLI をインストールし、エントリポイントに対して scriptc coverage を実行し、見出しの数値ではなく、その比率とブロッカーのリストに判断させてください。

FAQ

scriptc のバイナリを実行するマシンに Node.js や clang のインストールは必要ですか?

不要です。scriptc が必要とするものはすべてビルド時の要件です。コンパイラは Node.js 24 上で動作し、実行ファイルのビルドにはプラットフォームのリンカドライバと対応する SDK または sysroot が必要です。サポート対象の macOS、Linux、Windows ホストでは、LLVM 層が C をコンパイルする代わりにプリコンパイル済みランタイムパックをリンクするため、clang などの C コンパイラが必要になるのは明示的な C ビルド、LLVM フォールバック、サニタイザビルドの場合のみです。実行ファイル自体は Node を必要としません。静的ビルドには小さなネイティブランタイムのみが同梱され、Node も JavaScript エンジンも含まれません(コードが正規表現を使用する場合にリンクされる正規表現インタプリタを除く)。ir、c、llvm の emit ターゲットによるソース出力には Node だけがあれば十分です。

Mac から Linux や Windows のバイナリをビルドできますか?

はい。scriptc は macOS、Linux、Windows、および WASI Preview 1 経由の WebAssembly を対象とし、macOS arm64 がファーストクラスのホストです。zig によるクロスコンパイルは Linux と Windows のバイナリを得る 1 つの経路であり、両ターゲットには独自のネイティブヘルパーとランタイムパックも用意されていて、Linux x64 と arm64、Windows x64 をカバーします。WASI 経路は、環境変数 SCRIPTC_CC と SCRIPTC_TARGET をそれぞれ zigcc と wasm32-wasi に設定して駆動します。ソケット、子プロセス、ファイルシステム監視など Preview 1 に存在しない API は、リンク前に SC3002 で失敗します。

埋め込みエンジン上で動作する npm パッケージが、渡したオブジェクトを変更した場合はどうなりますか?

静的側にその変更は反映されません。動的ビルドでは、値は境界をまたいで共有されるのではなくコピーされます。そのため、エンジンで実行されたパッケージが変更した内容は静的側のオリジナルに影響を与えず、静的コードが変更した内容もエンジン側のコピーに影響を与えません。scriptc はこれを、JavaScript からの意図的な逸脱の 1 つとして挙げています。JavaScript であれば、双方が同一のオブジェクトを保持することになります。

npm 依存のコードを、エンジンで実行する代わりに静的にコンパイルできますか?

はい、実験的な --npm-static フラグで可能です。パッケージ名を指定するか auto を渡すと、コンパイラはそれらを埋め込みエンジンから取り出し、配布されている JavaScript を、パッケージ自身の宣言ファイルで型付けしたうえで静的なプログラムモジュールとしてコンパイルしようと試みます。カバレッジは高いものの部分的です。静的コンパイラが処理できない箇所は先送りされてレポートに名前が挙がり、プリフライトで却下されたパッケージはビルドを壊すのではなく、注記付きでエンジンへ戻されます。自分のパッケージのどれが通過するかは coverage を実行して確認してください。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.