🌼 はじめに
こんにちは、にわかエンジニアです。
私はエンジニア歴のほとんどを TypeScript と一緒に過ごしていてあまり気づいてなかったのですが、どうやら TypeScript のコンパイルは他の言語に比べて変わってるらしいです。
なので、TypeScript のコンパイルについて調べたものを共有したいと思います!
1. コンピューターの言語、人の言語
まずは根本的な言語(とその翻訳)について説明します。
1-1. 低水準言語
低水準言語(low-level language)は、コンピュータが理解しやすいように作られた言語で、一般的に機械語とアセンブリ言語を指します。
機械語(machine language)は CPU が直接実行する0と1の列で、機械に向けの言語で人が読むことは配慮していません。
01001000 11000111 11000000 00000001 00000000 00000000 00000000
01001000 10000011 11000000 00000010
11000011
私が学生時代によく聞いてた曲に0と1で構成されたデジタルがなんとかの歌詞がありましたが、それって機械語のことだったかーと今になって思います。
アセンブリ言語(assembly language)は、機械語を多少は人が読めるようにした言語です。先の機械語の例をアセンブリ言語で表現してみました。
mov rax, 1
add rax, 2
ret
mov(move)、add、ret(return)など自然言語が入って先よりはちょっとわかる気がします。
昔は人が機械語でプログラミングしないといけなく、それが厳しすぎて多少人に優しくなったアセンブリ言語が登場したようです。
その後人がもっと効率的にプログラミングするために高水準言語が登場します。
1-2. 高水準言語
高水準言語(High-level language)は、人が理解しやすい自然言語に近づけて作られた言語です。
例で偶数と奇数をトータルで10回出力しているC言語のコードを見てみましょう。
#include
int main(void) {
for (int i = 1; i 10; i++) {
if (i % 2 == 0) printf("%d: even\n", i);
else printf("%d: odd\n", i);
}
return 0;
}
もし「10回じゃなくて15回に出力に変更したい」って言われたら i の数字を直せばいいでしょう。 このように高水準言語は読みやすく、操作が簡単なという利点があります。現代はほとんど高水準言語で開発しているでしょう。
ですが、プログラムを実行する主体は人じゃなくて機械なので、人が書いた高水準言語を機械がわかるように低水準言語に翻訳してあげる必要があります。
その「高水準言語 → 低水準言語」の翻訳のやり方がコンパイル方式とインタプリタ方式です。
1-3. コンパイル
コンパイル(Compile)は、プログラム全体を実行ファイルに翻訳してから実行する方式です。
コンパイル方式の場合、実行する前に翻訳(高水準→低水準)する必要があり、その翻訳をしてくれるプログラムをコンパイラと言います。
コンパイル方式で翻訳する言語は C/C++、Rust、Go などがあります。C言語で例をあげてみましょう。
#include
int main(void) {
int a = 1, b = 2;
printf("Hello, world! sum=%d\n", a + b);
return 0;
}
hello.c
を実行するためには、まずgcc
というコンパイラーで実行ファイルを生成し、その実行ファイルを実行します。
gcc hello.c -o hello
./hello
コンパイル後すべてのコードが機械語に翻訳済みであるため、処理速度が速いというメリットがあります。
ただ、実行前にコンパイル必要だから初回実行まで時間かかる(+ ビルド時間かかる)というデメリットもあります。
1-4. インタプリタ
インタプリタ(Interpreter)は、プログラムを実行しながら1行/1ブロックずつ翻訳する方式です。コンパイル方式と違って翻訳と実行が同時に行われます。
インタプリタ方式の場合、コードを少しずつ翻訳しながら同時に実行もします。その翻訳と実行を同時に行うプログラムをインタプリタと言います。
インタプリタ方式で翻訳する言語は Python、Ruby、PHP などがあります。Python で例をあげてみましょう。
a, b = 1, 2
print(f"Hello, world! sum={a + b}")
hello.py
は事前翻訳なしですぐ実行できます。
インタプリタ方式は実行前の翻訳過程が不要なので、コードを修正してもすぐ実行できるというメリットがあります。
ただ、実行しながら翻訳する必要があるのでコンパイル方式と比べたら実行に時間がかかる場合があります。
2. TypeScript のコンパイルと実行
では具体的に TypeScript がどうやってコンパイルされるか見ていきましょう。
2-1. TS ファイル作成
まずは簡単に挨拶を出力するプログラムを TypeScript で書いてみました。
hello.ts
type Lang = 'ko' | 'ja' | 'en';
type User = {
name: string;
lang?: Lang;
};
const GREETINGS = {
ko: '안녕하세요',
ja: 'こんにちは',
en: 'Hello',
} as const
function greet(user: User): string {
const lang: Lang = user.lang ?? 'en';
return `${GREETINGS[lang]}, ${user.name}!`;
}
(function main() {
const me: User = { name: 'TS', lang: 'en' };
console.log(greet(me));
})();
2-2. コンパイル
書いた TS ファイルを公式の TypeScript コンパイラー(tsc
)でコンパイルします。
tsc
の仕事は大きく「1. 型チェック」と「2. JSへの変換」の2つあります。
まず「1. 型チェック」段階でエンジニアが書いたコードに型エラーがないか検査します。確認のために先ほど作成した hello.ts
で型エラーを発生させてみます。
type Lang = 'ko' | 'ja' | 'en';
type User = {
name: string;
lang?: Lang;
};
const GREETINGS = {
ko: '안녕하세요',
ja: 'こんにちは',
} as const
function greet(user: User): string {
const lang: Lang = user.lang ?? 'en';
return `${GREETINGS[lang]}, ${user.name}!`;
}
(function main() {
const me: User = { name: 'TS', lang: 'en' };
console.log(greet(me));
})();
この状態で tsc
を実行すると以下のようなエラーを出力されます。
src/hello.ts:16:15 - error TS7053: Element implicitly has an 'any' type because expression of type 'Lang' can't be used to index type '{ readonly ko: "안녕하세요"; readonly ja: "こんにちは"; }'.
Property 'en' does not exist on type '{ readonly ko: "안녕하세요"; readonly ja: "こんにちは"; }'.
16 return `${GREETINGS[lang]}, ${user.name}!`;
~~~~~~~~~~~~~~~
Found 1 error in src/hello.ts:16
エラーの確認できたのでコードを元にもどして型エラーを解消しました。
その次の「2. JSへの変換」段階で TypeScript ファイルを JavaScript ファイルに変換します。
大事なことなので2回言います。TypeScript コンパイラーは、TypeScript コードを JavaScript コードに変換します。
先ほど書いた TS ファイルを JS ファイルに変換したら以下のようになります。
hello.js
const GREETINGS = {
ko: '안녕하세요',
ja: 'こんにちは',
en: 'Hello',
};
function greet(user) {
const lang = user.lang ?? 'en';
return `${GREETINGS[lang]}, ${user.name}!`;
}
(function main() {
const me = { name: 'TS', lang: 'en' };
console.log(greet(me));
})();
型が全部消えたプレーンな JavaScript になりました。
一般的には「コンパイル」と言うと高水準言語を低水準言語に翻訳することを意味しますが、TypeScript のコンパイル結果はまだ全然高水準である JavaScript です。これが TypeScript コンパイルの変わったところでしょう。
TypeScript でいうコンパイルは「型チェック」+「JSへの変換」であり、「高水準 → 低水準」ではありません。
なので「いや厳密に言うとコンパイルじゃないじゃん」と思うかもしれませんが、公式がコンパイルって言ってるのでコンパイルということになってる気もします。
https://www.typescriptlang.org/docs/handbook/2/basic-types.html#tsc-the-typescript-compiler
2-3. JS ファイル実行
では生成された JavaScript コードを実行してみましょう。ローカルで手軽に実行するために node.js で動かしたら、挨拶してくれます。
node src/hello.js
Hello, TS!
JS ファイルをすぐ実行できたので JavaScript はインタプリタ方式を採用しているでしょう。
しかし、実は現代の JavaScript はインタプリタ&コンパイル両方やります。
ざっくりこういう歴史的な流れで両方やるようになりました。
- 元々 JavaScript はインタプリタ言語
- WEBの進化が激しく、どんどんリッチなUIが登場
- インタプリタだけだと実行が遅い!
- インタプリタで即実行しつつ、よく走る箇所はJITコンパイル(実行中にコンパイル)して性能改善
こうやって TypeScript は JavaScript にコンパイルされ、実行されます。
+)実行されるのは JS ファイルということに気をつけましょう
実行時は型情報が全部消えるので、想定してた型と実際の型が違う場合もすぐ気付けないことがあります。
例えば開発時は Product
のような型を期待していたのに、
type Product = { amount: number };
const response = await fetch("/api/product");
const product: Product = JSON.parse(response);
console.log(product.amount + 1);
現実世界の型は違くて、
予期せぬ動きになってもエラーにはならないのですぐは気付けないです。
console.log(product.amount + 1);
Rust、Kotlin など他の言語は期待してた型と違う時点でエラーをスローするからすぐ気づくらしいですね。TypeScript でも zod
などスキーマ検証すれば同じことはできますが、外部ライブラリーが必要になります。
3. ビルドとランタイムの疎結合
ではもう一度 TypeScript のコンパイルと実行を時系列に整理しえみましょう。
ここに一つ特異点があります。実行時は JS ファイルさえあれはいいので、TypeScript とか、ビルドの時に起きたこととかはどうでもよくなります。
この場合ビルドタイムとランタイムが疎結合(Loose Coupling)だと言えるでしょう。これがまた TypeScript の変わった部分です。
そういう特性なため、TypeScript はビルド時のツール入れ替えができます。
具体的には、トランスパイル(TS→JS)段階で公式のコンパイラー(tsc
)じゃなく別のツールを使うことができます。
しかし、トランスパイルは別のツールもできるけど、正確な型チェックは tsc
しかできないのが現状です。
ということでトランスパイルは別のツール使って早くしたのに型チェックには tsc
使う、というツール混在状態になり、使い勝手が微妙になることがあります。
TypeScript 公式のコンパイラー(tsc)は TypeScript で書かれていて、それが遅いです。
ですが、2025年3月11日 Microsoft が TypeScript のコンパイラを GO に書き換えて10倍早くすると発表しました。ムネアツですね。
TSGO がリリースされたら TypeScript のコンパイル(型チェック + トランスパイル)が爆速になり、別ツール使わず公式コンパイラだけで簡潔できてすっきりするんじゃないかなと思ってます。
正式リリースはまだですが、早くリリースしてほしいなと思う日々を送っています。
TypeScript コンパイルまわりだいぶふわっとしてましたが、これをきに色々理解できてよかったです。
それと今かかってるプロジェクトがかなり大規模なコードベースで、tsc
もめちゃくちゃ時間かかってるので TSGO 早く来てほしいです(切実)
Views: 0