はじめに:tsc って結局、中で何してるの?
.ts ファイルを書いて tsc を叩くと、型がきれいさっぱり消えた .js が出てきます。毎日やっている操作ですが、あの一瞬で中身では何が起きているのか、ちゃんと説明できるでしょうか?
この記事では、TypeScript が JavaScript に「化ける」までの工程を、実際の変換結果を見ながら追いかけます。先に結論だけ言っておくと、こうです👇
トランスパイルの本体は「型を消す」+「古い構文を書き換える」だけ。型チェックは、実は別の仕事。
この一文がピンとこない人ほど、最後まで読むと世界が変わります。ついでに2026年の二大事件(Node が自分で型を剥がし始めた件と、tsc 本体が Go に引っ越した件)まで一気に駆け抜けましょう。
動作確認や用語は2026年6月時点のものです。バージョン番号の進みが速い領域なので、最新情報は末尾の公式リンクで確認してください。
大前提:tsc は「二刀流」だった
多くの人が「TypeScript のコンパイル = 型チェック」だと思っています。半分正解で、半分は誤解です。
tsc の仕事は、実はまったく独立した2つに分かれています。
- 型チェック(type checking) … 型が合っているかを検査してエラーを出す
-
コード生成(emit) … 型を消し、古いブラウザでも動く構文に書き換えて
.jsを吐く
ここが今日いちばん大事なポイントです。②のコード生成は、①の型チェックを必要としません。 型注釈を消して構文を書き換えるだけなら、型が合っているかどうかは関係ないからです。
この「分離」を知っていると、後半で出てくる esbuild / swc / Node が「なぜ型チェックなしで一瞬で変換できるのか」がスッと理解できます。逆にこれを知らないと一生モヤモヤします。
全体図:ソースが .js になるまでの工程
まずは地図から。tsc の内部は、大きく 6つの段階 に分かれています。ソースコードが、段階ごとに別の「データのかたち」へと姿を変えていくイメージです。
ポイントは Checker(型チェック)が脇道に逸れていること。型チェックは「型エラーの報告」という別の出口を持っているだけで、生成される .js には直接影響しません。点線がその「別レイヤー感」です。
後で出てくる爆速トランスパイラたちは、この Checker をまるごとスキップして D → F → H だけ(むしろ B → 簡易変換 → H)を走ります。だから速い。覚えておいてください。
各工程を実況中継
例として、こんな1行を流してみます。
const msg: string = "Hello";
① Scanner(字句解析器):文字をトークンに切り刻む
ソースを1文字ずつ読み、意味のある最小単位「トークン」の列に分解します。
const → ConstKeyword
msg → Identifier
: → ColonToken
string → StringKeyword
= → EqualsToken
"Hello"→ StringLiteral
; → SemicolonToken
② Parser(構文解析器):トークンから AST を組み立てる
トークン列を読んで、木構造の AST(抽象構文木) を作ります。各ノードは SyntaxKind という enum で種類が識別されます。たとえば a + 1 なら、Identifier(a)・PlusToken・LiteralExpression(1) を子に持つ BinaryExpression になります。
VariableStatement
└─ VariableDeclaration
├─ name: msg
├─ type: StringKeyword ← この型情報、後で消されます
└─ initializer: "Hello"
③ Binder:名前と宣言を結びつける
AST を一巡して シンボルテーブル(識別子 ↔ 宣言の対応表)を作り、スコープを構築します。ここが地味に重要で、Binder はこのとき各ノードに「これはどの ES バージョンの構文か(ES2015 固有なのか、TypeScript 固有なのか)」という印もこっそり付けます。これが後段の変換の下準備になります。
④ Checker(型チェッカー):TypeScript の心臓部
AST とシンボルを使って、型推論・型互換性チェック・ジェネリクス解決・制御フロー解析(ナローイング)などを行い、型エラーを報告します。number に文字列を代入していたら怒るのはここの仕事です。
TypeScript のソースコードの中でもダントツに巨大なコンポーネントで、TypeScript の「賢さ」のほぼ全部がここに詰まっています。そして繰り返しますが、この工程は emit には本質的に不要です。
⑤ Transformer:型を消し、古い構文に書き換える
ここが「化ける」瞬間です。TypeScript 変換は基本的に型注釈をポイっと捨てるだけ。ただし target が古い場合は、新しい構文を古い JavaScript で実装し直すコードを生成します(例:ES2015 → ES5)。新しい方言から古い方言へ、段階的にダウンレベルしていくイメージです。
⑥ Printer / Emitter:AST を文字列にして書き出す
変換後の AST を、最終的な JavaScript の文字列として出力します。.js と同時に、.d.ts(型定義ファイル)やソースマップもここで生成されます。
最終的に、最初の1行はこうなります。
const msg = "Hello";
型が消えただけ。シンプルですね。……が、世の中はこんなに平和な例ばかりではありません。
「型消去」が変換の本体
TypeScript には、こういう設計原則があります。
型は、実行時の振る舞いに一切影響しない。
だから interface や type、型注釈は、出力 JS から完全に消滅します。痕跡すら残りません。
interface User {
id: number;
name: string;
}
const greet = (u: User): string => `Hi, ${u.name}`;
↓ コンパイル後
const greet = (u) => `Hi, ${u.name}`;
interface User は影も形もありません。これが「型消去(type erasure)」です。
ただし例外があります。enum / namespace / (レガシーな)デコレータ / クラスの「パラメータプロパティ」などは、実行時のコードを生成します。つまり「ただ消すだけ」では済まない、TypeScript の中の少数派です。この少数派が、のちのち大事件を引き起こします。
実録:あなたのコードはこう化ける
ここからが本記事の見せ場。「消えるだけ」じゃない連中の、衝撃のビフォーアフターを見ていきましょう。
ケース1:enum — 伝説の IIFE 逆引きマッピング
何気なく書いたこの enum 👇
enum UserRole {
Admin, // 0
Editor, // 1
Viewer, // 2
}
コンパイル後、こうなります。
"use strict";
var UserRole;
(function (UserRole) {
UserRole[UserRole["Admin"] = 0] = "Admin";
UserRole[UserRole["Editor"] = 1] = "Editor";
UserRole[UserRole["Viewer"] = 2] = "Viewer";
})(UserRole || (UserRole = {}));
初見だと「は?」となるやつです。これは即時実行関数(IIFE)で、双方向のマッピングを作っています。UserRole[UserRole["Admin"] = 0] = "Admin" という1行で、
-
UserRole["Admin"]→0(名前から値) -
UserRole[0]→"Admin"(値から名前、これを reverse mapping と呼びます)
の両方を同時に仕込んでいるわけです。公式ハンドブックでも、numeric enum は「名前→値」と「値→名前」の両方を持つオブジェクトにコンパイルされる、と明言されています(string enum には逆引きは生成されません)。
「enum はやめて as const を使え」という意見をよく見かけるのは、このランタイムへの重さと、interface のように消えてくれない非対称さが理由です。
const UserRole = {
Admin: 0,
Editor: 1,
Viewer: 2,
} as const;
type UserRole = (typeof UserRole)[keyof typeof UserRole];
こちらは素の JavaScript に素直に化けるので、tree-shaking もしやすく、後述の「型を剥がすだけ」のランタイムでもそのまま動きます。
ケース2:クラスの「パラメータプロパティ」
コンストラクタ引数に修飾子を付けるあのショートハンドも、ただの構文糖です。
class Person {
constructor(private name: string, private age: number) {}
}
↓
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
private が消え、代入文に展開されます。型は消えるけど、代入というランタイム動作が増えるのがミソ。
ケース3:async/await を ES5 に落とす — ステートマシン爆誕
ここが個人的に一番ロマンがあります。target を古い ES5 にすると、async/await は ジェネレータを模したステートマシンに変換されます。
async function foo() {
let result = Math.random();
if (result < 0.8) return "Recursion: " + await foo();
else return "Result: " + result;
}
↓ ES5 ターゲット時のemit(概形)
function foo() {
return __awaiter(this, void 0, void 0, function () {
var result, _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
result = Math.random();
if (!(result < 0.8)) return [3 /*break*/, 2];
_a = "Recursion: ";
return [4 /*yield*/, foo()]; // ← ここで「中断」
case 1:
return [2 /*return*/, _a + _b.sent()]; // ← 戻ってきたら続きから
case 2:
return [2 /*return*/, "Result: " + result];
}
});
});
}
注目は switch (_b.label) の部分。これがステートマシンそのものです。
-
return [4 /*yield*/, foo()]は「ここで処理を中断して制御を手放す(=await)」の意味 - Promise が解決したら、
labelを進めて次のcaseから再開する
await のたびに関数を抜けて、後でその続きから入り直す——という「一時停止と再開」を、ただの数値ラベルと switch で再現しているわけです。これを支えるのが、コンパイラが注入する2つのヘルパー関数 __awaiter(Promise でラップ)と __generator(ステートマシン本体)です。
target を ES2017 以上にすると、async/await はネイティブ構文のまま出力され、このステートマシンは生成されません。つまり上記の変換は「古い実行環境のための互換コード」。__awaiter などのヘルパーは各ファイルにインライン展開されますが、importHelpers を付けると tslib から読み込む形にできて、出力が太るのを防げます。
まとめ表:消えるやつ・化けるやつ
| 機能 | 変換のされ方 | ランタイムへの影響 |
|---|---|---|
型注釈 / interface / type
|
完全消滅 | なし(純粋な型消去) |
optional chaining a?.b(ES2019以下) |
条件分岐に展開 | 構文ダウンレベル |
async/await(ES5) |
__awaiter + ステートマシン |
ヘルパー注入 |
| パラメータプロパティ | コンストラクタ内の代入に展開 | 代入が増える |
enum |
IIFE + 逆引きオブジェクト | バンドルに残る |
namespace |
IIFE | バンドルに残る |
だから「型チェックしないトランスパイラ」が世にあふれている
冒頭の伏線を回収します。型チェックと変換が分離できるからこそ、世の中には「型チェックはしないけど、型を消す作業だけは爆速」というツールが大量にあります。
-
Babel(
@babel/preset-typescript)… ファイル単位でパースして型を消すだけ。型チェックなし。 - esbuild(Go製)/ swc(Rust製)… パース → 型ストリップ → 構文変換のみ。型解決をしないので1ファイルずつ独立に処理でき、これが爆速の源。
これらは型情報を使わないため、ファイルをまたいだ型の解決ができません。また .d.ts の生成には解決済みの型情報が必要なので、こちらは tsc(の API)の出番です。
そんなわけで、実務での定番はこの分業体制です👇
型チェックは
tsc --noEmitに任せ、変換は esbuild / swc / Babel にやらせる。
| 型チェック | コード生成 | .d.ts |
速度感 | 代表例 | |
|---|---|---|---|---|---|
tsc / tsgo
|
✅ | ✅ | ✅ | 遅い → Go で高速化 | 公式コンパイラ |
| esbuild / swc / Babel | ❌ | ✅ | ❌ | 爆速 | バンドラ・ビルド系 |
| Node 型ストリッピング | ❌ | △(剥がすだけ) | ❌ | 爆速・ビルド不要 | ランタイム内蔵 |
事件①:Node が、自分で型を剥がし始めた
「トランスパイル時に何が動いているか」という観点で、2024〜2025年の大きな変化がこれです。Node.js 自体が TypeScript の型を剥がして、.ts を直接実行できるようになりました。
# Node 22.6+(実験的フラグ)
node --experimental-strip-types app.ts
# Node 23.6+ ではフラグ不要で実行できる
node app.ts
中で動いているのは Amaro というライブラリで、その正体は @swc/wasm-typescript(Rust 製 swc の WebAssembly 版)です。つまり Node は、内部に swc を抱え込んで型を剥がしているわけです。
おもしろいのが型の消し方。型注釈を「削除」するのではなく、空白に置き換えています。
const foo: string = "bar";
↓ 内部的にはこんな感じ(型のあった場所が空白に)
const foo = "bar";
こうすると行・列のオフセットがズレないので、ソースマップなしでもスタックトレースが元コードと一致します。地味ですが賢い。
ただし、Node がやっているのは「型を剥がすだけ」で、型チェックは一切しません。型安全性の担保には別途 tsc --noEmit が必要です。
さらに、デフォルトの strip モードでは消すだけしかできないので、enum や namespace のようにコード生成が必要なやつは動きません。どうしても使うなら --experimental-transform-types が必要になります。……ほら、また少数派の enum が引っかかってきました。
事件②:enum、おまえだったのか —— erasableSyntaxOnly と「型はコメント」の未来
Node のように「剥がすだけ」のランタイムが主流になると、「消せる構文」と「消せない構文」の線引きが急に重要になります。
そこで TypeScript 5.8 に登場したのが --erasableSyntaxOnly フラグ。これを有効にすると、「消すだけでは済まない構文」をコンパイルエラーにしてくれます。
// ❌ enum は消せない(コード生成が必要)
enum Direction { Up, Down }
// ❌ ランタイムコードを持つ namespace
namespace Container { export const x = 1; }
// ❌ パラメータプロパティ
class Point {
constructor(public x: number, public y: number) {}
}
「消せる構文(erasable syntax)」だけで書け、という強制ギプスですね。verbatimModuleSyntax(型だけの import を import type で明示させる)と組み合わせるのが定番で、これが通れば「Node の型ストリッピングで確実に動く」というお墨付きになります。
そして、この流れの背景にはもっと大きな絵があります。TC39(JavaScript の標準化団体)で進む 「Types as Comments(型をコメント扱いする)」 提案です。
ブラウザが、TypeScript の型構文をコメントのように無視して実行できるようにする。
// 未来の JavaScript(?)
function add(a: number, b: number): number {
return a + b;
}
// ブラウザ:「a: number ? 知らんけど無視して実行するわ」
これが実現すれば、ビルドステップなしで TypeScript がそのまま動く未来が来ます。提案のチャンピオンは TypeScript の PM、Daniel Rosenwasser 本人。ただし条件は「型が無視できること」。enum のように実際の JS を生成する構文は、この未来の蚊帳の外なのです。
整理すると、TypeScript はいま**「型はそもそも消すものだ」という思想**に大きく舵を切っています。enum が肩身の狭い思いをしているのは、こういう時代の流れがあるからなんですね。
事件③:2026年最大の衝撃 —— tsc 本体が Go に引っ越した
最後はコンパイラそのものの話。2026年、tsc は Go へ移植されました。コードネームは Project Corsa、コマンドは tsgo です。
ここまで「中で何が動いているか」を見てきましたが、その実装言語が JavaScript から Go に変わった、というのがこの事件の核心です。
タイムライン
- TypeScript 6.0(2026年3月23日)… 最後の JavaScript ベース版。5系から7系への橋渡しリリース。
-
TypeScript 7.0 Beta(2026年4月21日)… Go ベースの新コンパイラ
tsgoの公開ベータ。6.0 比でしばしば約10倍速いとされる。
なぜ速くなる?
公式(Anders Hejlsberg)いわく、10倍のうち半分はネイティブコード化、もう半分は共有メモリの並列処理から来ているとのこと。型チェックを複数コアでぶん回せるようになったのが大きい。
なぜ「作り直し」ではなく「移植」なのか
ここが面白いところ。チームはゼロからの再設計ではなく、関数単位の移植を選びました。理由は、巨大で複雑な型チェッカーには「そのコードの正確な挙動の中にしか書かれていない振る舞い」が大量にあるから。下手に作り直すと、微妙に挙動の違う「互換性のない TypeScript」が生まれて誰も使えなくなる——という判断です。だから新コンパイラは、過去のクセまで含めて旧来と同じように設計されています。
なぜ Go なのか(Rust じゃなく)
公開直後、ネットは「なぜ Go? なぜ Rust や C# じゃない?」で大荒れしました。TypeScript と C# の両方を生み出した Hejlsberg 本人が火消しに回るほど。要点はこうです。
- Go:自動 GC を持ちつつ到達できる最も低レベルな言語。全プラットフォームでネイティブコード、データレイアウトの細かい制御、そして循環参照だらけのデータ構造を素直に扱える。
- Rust 不採用:TypeScript コンパイラの内部データ構造は循環参照まみれで、Rust の所有権・借用チェッカーと根本的に相性が悪い。これを解きほぐすと移植が「年単位+互換性崩壊」コースになる。
- C# 不採用:C# はバイトコード前提でネイティブ志向ではなく、コードベースが関数とstruct中心で OOP 的でないため、Go の方が移植しやすかった。
開発リードの Ryan Cavanaugh の言い回しが秀逸でした。要約すると「Rust は設計目標を見事に達成している。ただし『この特定の JS コードベースから移植しやすい』ことは、まったく合理的に、その設計目標に入っていなかった」。
内部アーキテクチャは旧来とほぼ同じで、internal/scanner / parser / binder / checker / printer といったパッケージ構成を踏襲しています。この記事で見てきた6段階の工程は、Go 版でもそのまま生きているわけです。
ただし注意点として、emit(JS出力)・--watch・API はまだ完成途上で、型チェックが主要な互換性に達している段階です。「型チェックだけ爆速でやりたい」用途なら今すぐ試せます。
手元で覗いてみよう
理屈がわかったら、自分の目で確かめるのが一番楽しいです。
変換結果を見る
-
TypeScript Playground … 左に
.ts、右に emit 後の.js。targetを切り替えるとenumやasyncの化け方が変わる様子を観察できます。 - ts-ast-viewer.com … AST・シンボル・型・emit を全部可視化。Scanner〜Emitter の各段階を目で追えます。
コマンドで試す
# ES5 に落として、async/await のステートマシンを拝む
npx tsc --target ES5 app.ts && cat app.js
# 「消せる構文」だけで書けているかチェック
npx tsc --noEmit --erasableSyntaxOnly app.ts
# Go 版コンパイラ tsgo のプレビューを入れてみる
npm i -D @typescript/native-preview
# Node に直接 .ts を食わせる(22.18+ / 23.6+)
node app.ts
まとめ
長旅おつかれさまでした。持ち帰ってほしいのは、この3点です。
- トランスパイルの本体は「型消去 + 構文ダウンレベル」。型チェックはそれとは独立した別レイヤー。だから esbuild / swc / Node は型チェックを飛ばして爆速で変換できる。
-
enumなどの少数派は実行時コードを生成する。interfaceのように消えてくれないこの非対称さが、erasableSyntaxOnlyや「Types as Comments」という**「型はそもそも消すもの」という時代の流れ**を生んでいる。 -
2026年、
tsc本体が Go(tsgo)に引っ越した。工程の中身は同じまま、実装言語を変えて約10倍速へ。循環参照だらけの設計ゆえに Rust ではなく Go が選ばれた、という裏話つき。
次に tsc を叩くとき、あの一瞬で Scanner がトークンを刻み、Parser が木を組み、型がふわっと消えて、enum がこっそり IIFE に化けている——そんな舞台裏を思い出してもらえたら嬉しいです。
参考リンク
- TypeScript Handbook — Enums(公式)
- Announcing TypeScript 5.8(公式ブログ・
erasableSyntaxOnly) - TypeScript 5.8 Release Notes(公式)
- TypeScript Compiler Internals — TypeScript Deep Dive
- The TypeScript compiler implementation manual(sandersn)
- Compiling async/await to ES3/ES5 in TypeScript(Marius Schulz)
- amaro(npm・Node の型ストリッピング実装)
- Microsoft TypeScript Devs Explain Why They Chose Go Over Rust, C#(The New Stack)
- TypeScript is being ported to Go — interview with Anders Hejlsberg(YouTube)