TypeScriptはなぜランタイム構文を作り、なぜ今それを取り除きつつあるのか
はじめに
「enumはツリーシェイキングができないからas constを使え。」TypeScriptプロジェクトでよく聞くアドバイスだ。リントルールで強制するチームもあれば、コードレビューで指摘されることもある。
しかし実際のところ、enum数個がバンドルサイズに与える影響は微々たるものだ。パフォーマンスの話だけでは、なぜここまで推奨されているのか説明がつかない。namespaceやparameter propertiesのように、パフォーマンスとは無関係な構文も同じ文脈で非推奨とされているのはなぜだろうか?
この記事は、その疑問から出発する。
TypeScript初期:JavaScriptにないものを独自に作っていた時代
TypeScriptが初めて登場した2012年、JavaScriptは今とはかなり違っていた。ES Modules(import/export)は標準化されておらず、クラス構文もなく、コードを構造化する手段が不足していた。
このような環境で、TypeScriptは型システムを追加しただけではなかった。JavaScriptにまだ存在しないランタイム機能までも独自に提供していた。
-
enum:定数の集合を定義する仕組みがJavaScriptになかったため、TypeScriptがランタイムオブジェクトを生成する構文を作った -
namespace(初期はmodule):ES Modulesがなかった時代、コードを論理的にグループ化する手段が必要だった - parameter properties:クラスのコンストラクタでプロパティの宣言と代入を省略する便利な構文を提供した
-
import = require():CommonJSとTypeScriptのモジュールシステムを繋ぐ専用構文を作った
これらの構文は、当時としては合理的な選択だった。JavaScript自体が提供できないものを、TypeScriptが補っていたのだ。
JavaScriptの成熟:ランタイム機能の存在理由が薄れていく
しかし、JavaScriptは急速に発展していった。
-
ES2015 (ES6):
class構文、import/export(ES Modules)、const/let、アロー関数など -
ES2020以降:
#privateフィールド、optional chaining、nullish coalescingなど
同時に、TypeScript側でも型レベルの代替手段が登場した。
-
as const(TypeScript 3.4):通常のオブジェクトをリテラル型として固定し、enumの大部分の役割を代替可能
ES Modulesが標準になったことでnamespaceの必要性がなくなり、as constの登場によりenum無しでも定数の集合を型安全に定義できるようになった。parameter propertiesは依然として便利な省略構文だが、型を消すだけでは有効なJavaScriptにならない。ランタイムで別のコードへの変換が必要になるという点で、他の構文と同様の問題を抱えている。
こうして、TypeScriptがランタイムレベルで独自に提供していた機能は、JavaScript標準やTypeScriptの型レベル機能で代替できる状態になった。
「型を消せばJavaScriptになる」という原則
この変化を貫く原則が一つある。
TypeScriptのコードから型構文を取り除けば、それ自体が有効なJavaScriptになるべきだ。
TypeScriptのほとんどの構文はこの原則に従う:
// TypeScript
const x: number = 1;
interface User {
name: string;
}
function greet<T>(value: T): T {
return value;
}
// 型を消した後 — 有効なJavaScript
const x = 1;
function greet(value) {
return value;
}
: number、interface User { ... }、<T>、: T — すべて消せばそのまま動作するJavaScriptが残る。型アノテーションはコンパイル時の検査用としてのみ存在し、ランタイムの動作には影響を与えないからだ。
しかし、先に述べたランタイム構文はこの原則に従わない。型を消すだけでは有効なJavaScriptにならず、新しいコードを生成しなければならない。
TypeScript公式Handbookでもこの特殊性を明示している:
"Enums are one of the few features TypeScript has which is not a type-level extension of JavaScript."
"few features" — 少数の例外という表現自体が、TypeScriptが自身を「型レベルの拡張」として位置づけていることを物語っている。
ランタイムコードを生成する構文
では具体的に、どの構文がこの原則から外れるのか、そしてなぜ「型の除去」では処理できないのかを見ていこう。
enum
enumはTypeScriptで最も広く使われているランタイム構文だ。コンパイル時に消えるのではなく、実際のJavaScriptオブジェクトを生成する。
enum Direction {
Up,
Down,
Left,
Right,
}
このコードは以下のようなIIFE(即時実行関数)に変換される:
var Direction;
(function (Direction) {
Direction[(Direction["Up"] = 0)] = "Up";
Direction[(Direction["Down"] = 1)] = "Down";
Direction[(Direction["Left"] = 2)] = "Left";
Direction[(Direction["Right"] = 3)] = "Right";
})(Direction || (Direction = {}));
enum Direction { ... }全体が新しいJavaScriptコードに変換されなければならない。「型を消す」という概念だけでは処理しきれない。
IIFEパターンには副次的な問題もある。バンドラー(webpack、Rollup、esbuildなど)がツリーシェイキングを行う際、IIFEはサイドエフェクトがあるものとみなされる可能性があり、使用していないenumもバンドルに残ることがある。実務で体感できるほどの差は稀だが、構造的には不利だ。
代替手段:as constオブジェクト
TypeScript公式Handbookでも代替案を直接提示している:
"In modern TypeScript, you may not need an enum when an object with
as constcould suffice"
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
type Direction = (typeof Direction)[keyof typeof Direction];
// Direction型は 0 | 1 | 2 | 3
as constは型アサーション(type assertion)であるため、除去すれば有効なJavaScriptになる。コード生成は不要で、通常のオブジェクトなのでツリーシェイキングも可能だ。
ただし、enumの逆方向マッピング(Direction[0] === "Up")はas constパターンではデフォルトで提供されない。
Handbookはこのパターンを支持する最も強力な論拠として、次のように述べている:
"The biggest argument in favour of this format over TypeScript's
enumis that it keeps your codebase aligned with the state of JavaScript"
namespace(ランタイムコードを含む場合)
namespaceはES Modules以前の時代にコードをグループ化するために作られた。内部にランタイム値(変数、関数など)を含む場合、IIFEに変換される必要がある:
namespace Utils {
export function log(msg: string) {
console.log(msg);
}
}
ES Modulesが標準となった現在、ファイル単位でモジュールを分け、import/exportを使えばnamespaceは不要だ:
// utils.ts
export function log(msg: string) {
console.log(msg);
}
// main.ts
import { log } from "./utils.ts";
なお、型のみを含むnamespaceは全体が型であるため除去対象であり、問題にはならない:
// 型のみ含む — コンパイルすると何も残らない
namespace Geometry {
export interface Point {
x: number;
y: number;
}
export type Distance = number;
}
内部にインターフェースと型のみがあるため、丸ごと消すことができる。一方、先の例のように関数や変数などのランタイム値が含まれるとIIFE変換が必要になる。
Parameter Properties
クラスのコンストラクタのパラメータにpublic、privateなどのアクセス修飾子を付けて、宣言と代入を省略する構文だ:
class User {
constructor(
public name: string,
private age: number,
) {}
}
これは以下のようなJavaScriptに変換される必要がある:
class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
publicは単なる型アノテーションではなく、this.name = nameという代入コードの生成を意味する。publicと型を除去するとconstructor(name, age) {}という有効なJavaScriptにはなるが、プロパティの代入が消えるため同一の動作をするJavaScriptにはならない。
代替手段:プロパティの宣言と代入を明示的に記述する。
class User {
name: string;
private _age: number;
constructor(name: string, age: number) {
this.name = name;
this._age = age;
}
}
import = require()
TypeScript専用のモジュールインポート構文で、CommonJSのrequireをTypeScriptの型システムと連携させるために作られた:
import express = require("express");
ES ModulesのimportやCommonJSのconst x = require('x')で代替できる。
エコシステムがこの方向性を強化している
TypeScript自体の設計方針の変化に加え、コンパイラオプションから外部ツール、JavaScript標準まで、エコシステム全体が「型を消せばJavaScript」という原則を前提に構築されている。
TypeScript 5.8:--erasableSyntaxOnly
TypeScript 5.8(2025年2月)で--erasableSyntaxOnlyコンパイラオプションが追加された。このフラグを有効にすると、型の除去だけでは有効なJavaScriptにならない構文をコンパイルエラーとして処理する:
// tsconfig.json
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}
このオプションを有効にすると、enum、namespace(ランタイムコードを含む場合)、parameter properties、import = require()などがエラーになる。つまり、先に説明したランタイムコード生成構文をコンパイラレベルで禁止できる公式オプションが誕生したのだ。
デフォルトはfalseなので、既存プロジェクトに影響はない。しかし、TypeScriptチームがこのようなオプションを公式リリースに含めたこと自体に意味がある。
Node.js公式ドキュメントでも、TypeScriptと併用する際の推奨tsconfig.jsonにこのオプションが含まれている。
// Node.js推奨tsconfig.jsonからの抜粋(全設定はNode.jsドキュメント参照)
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"module": "nodenext",
"target": "esnext"
...
}
}
高速ビルドツールの登場
esbuild(Go)、SWC(Rust)などのツールは、TypeScriptを処理する際に型検査を行わない。型をコメントのように扱い、除去するだけだ:
"TypeScript types are treated as comments and are ignored by esbuild, so TypeScript is treated as 'type-checked JavaScript.'"
これらのツールが高速な理由はシンプルだ。プロジェクト全体の型関係を分析する必要がなく、ファイルを一つずつ独立して処理しながら型構文を除去するだけで済むからだ。このアプローチは、TypeScriptのコードが「型を消せば有効なJavaScript」である場合にこそ、きれいに成り立つ。
ランタイムコード生成構文(enumなど)もこれらのツールが処理してくれるが、本質的には単純な除去以上の作業が必要な例外ケースだ。
Node.jsのネイティブTypeScriptサポート
Node.js v22.6.0から、TypeScriptファイルを直接実行できるようになった。内部的にはAmaroというモジュールが型を空白に置換(Type Stripping)する方式で処理する。
Node.jsはこの機能のサポート範囲を「型を消せば有効なJavaScriptになる構文」に意図的に制限した:
"The type stripping feature is designed to be lightweight. By intentionally not supporting syntaxes that require JavaScript code generation, and by replacing inline types with whitespace, Node.js can run TypeScript code without the need for source maps."
enum、namespace、parameter propertiesなどは、デフォルトモード(strip-only)ではエラーが発生する。別途--experimental-transform-typesフラグで処理することは可能だが、デフォルトの動作ではないという点自体がこれらの構文の立ち位置を物語っている。
TC39 Type Annotations提案
TC39(JavaScript標準化委員会)では、JavaScript自体に型アノテーション構文を追加する提案が進行中だ。現在Stage 1の段階で、2022年3月に提案されて以降、まだ初期段階にとどまっている。核心は、型構文をJavaScriptの公式文法として認めつつ、ランタイムではコメントのように無視するというものだ。
"This proposal aims to enable developers to add type annotations to their JavaScript code, allowing those annotations to be checked by a type checker that is external to JavaScript. At runtime, a JavaScript engine ignores them, treating the types as comments."
つまり、別途のビルドステップなしに.jsファイルに型を直接記述できるようになる:
// user.js — これ自体が有効なJavaScriptになる
function greet(name: string): string {
return `Hello, ${name}`;
}
const age: number = 30;
JavaScriptエンジンは: string、: numberのような型構文を文法的に許容しつつ、完全に無視する。型検査は依然としてTypeScriptのような外部ツールが担当する。
この提案でも、ランタイムコードを生成するTypeScript構文は明示的に除外されている:
"Some constructs in TypeScript are not supported by this proposal because they have runtime semantics, generating JavaScript code rather than simply being stripped out and ignored."
Node.jsのstrip-only、esbuildの型除去方式、TC39提案 — すべて同じ原則に基づいている:型は消せば終わりでなければならない。
では既存のコードを書き換えるべきか?
いいえ。 方向性を理解することと、既存のコードを今すぐマイグレーションすることは別の問題だ。
すでにenumを使っているプロジェクトでas constに切り替えて得られる実質的なメリットは限定的だ:
-
ビルドツールが処理してくれる:Vite、Next.js、webpackなどのフロントエンドビルド環境では、
enumは何らビルド上の問題を起こさない -
ツリーシェイキングの差は微々たるもの:
enum数個がバンドルに残っても、体感できる差は生じない - マイグレーションコストは確実にある:型推論の方式が変わり、逆方向マッピングを使っているコードには別途の対応が必要になる
この記事で扱っているのは「今すぐコードを書き換えろ」ということではなく、新しいコードを書く際にこれらの構文をあえて選ぶ理由がないということだ。TypeScriptとJavaScriptのエコシステムが収束していく方向を知っていれば、新しいコードを書く際の選択は自然とその方向に沿ったものになる。
まとめ
TypeScriptは初期、JavaScriptの不足を補うためにランタイム機能を独自に作った。JavaScriptが成熟するにつれてそれらの存在理由は薄れ、TypeScriptは本来の姿である**「JavaScript + 型」**へと回帰しつつある。
この流れは、TypeScript自体の設計方針(Handbookのas const推奨、--erasableSyntaxOnlyオプション追加)でも、ツールエコシステム(esbuild、SWC、Node.js strip-only)でも、そしてJavaScript標準(TC39 Type Annotations提案)でも一貫して現れている。
一方、TypeScript 7(Project Corsa)はコンパイラをGoで書き直し、ビルド速度を約10倍向上させるプロジェクトで、2026年初頭のリリースを目指している。既存の構文やセマンティクスは変更しないが、型検査(type-checking)に集中し、JavaScriptコードの生成(emit)はesbuild、SWCなどの外部ツールに委ねる構造をさらに強化する。「型はTypeScriptが検査し、実行可能なコードは別のツールが生成する」という役割分担が、コンパイラのアーキテクチャレベルで固まりつつある。
ランタイムコードを生成する少数の構文(enum、namespace、parameter properties、import = require())は、この流れの中に残された名残だ。既存のコードを急いで書き換える必要はないが、新しいコードであえてこれらの構文を選ぶことは、エコシステムの向かう方向に逆行することになる。
Discussion