Programming Field

[TypeScript] classの条件付きexportもどき

TypeScriptでclassをexportする際、条件を与えてそれに該当する場合にはクラスの中身を省略する(「exportしない」に近い)という方法を探ってみたページです。

問題の概要

TypeScriptでクラスをエクスポートするとき、通常は

export class TestClass {
    data1: number;
    constructor(a: number, b: number) {
        this.data1 = a * b;
    }
    myMethod1(): void {
    }
}

のように「export class」とするか、先にクラスを定義して「export { TestClass }」などと記述します。

ここで、「デバッグ用途でのみ使用されることを想定したクラス」など、ビルド条件などでクラスのexportを切り替えたい場合が中にはあるかもしれませんが、以下のように記述することはできません。

// ビルドオプションにより「デバッグ用途」であれば false、それ以外は
// true になるような変数「__PROD__」をglobalに宣言
// (ビルド時に webpack などで値を与える想定)
declare global {
    const __PROD__: boolean;
}

class TestClass2 {
    data1: number;
    constructor(a: number, b: number) {
        this.data1 = a * b;
    }
    myMethod1(): void {
    }
}

if (!__PROD__) {
    // エラー(TS1233): exportはmodule直下でのみ利用可能
    export { TestClass2 };
}
// エラー(TS4025): privateな「TestClass2」を外に出すことができない
// (※ 以下の文では __PROD__ == true の場合「TestClass2Export」は
//     null でexportされ、実体が定義されないという想定)
export const TestClass2Export = __PROD__ ? null : TestClass2;

もしTypeScriptにC/C++のようなディレクティブ(#ifdef ~ #endif など)が使用できれば解決するのですが、現状(2017/07/09時点)では用意されておらず、簡単には解決できなさそうです。1番目の「export」をifで囲む方法はそもそも構文エラーのため不可能ですが、2番目の条件演算子で切り替える方法は、エラーの理由が「privateであるため」となっているため、ここを工夫すれば回避できそうです。

※ 2番目の方法で解決したとしても名前自体はexportされてしまいます。ただし、上記のようにビルド時に条件を定数化すれば minify 時に最適化され、クラス内の実行コードを省略することができます。
※ 独自のプリプロセッサーなどを用意してコンパイラーに与えるファイルデータの時点で対策を行う方法も考えられますが、ここでは考えません。

classの実態

TypeScriptにおける「class」はTypeScript言語仕様によると、実際には「名前付きの型(クラス型)」と「名前付きの値(コンストラクター関数)」の2種類を定義しています。このうち「名前付きの型」は「interface」構文で定義する型と同じ扱いになります。例えば、

class TestClass2 {
    data1: number;
    constructor(a: number, b: number) {
        this.data1 = a * b;
    }
    myMethod1(): void {
    }
}

という記述は、「名前付きの型」の定義としては

interface TestClass2 {
    data1: number;
    myMethod1(): void;
}

と全く同じ意味になります(言語仕様で明言されています)。

では「コンストラクター関数」(「名前付きの値」)についてどうなるかというと、以下のような変数で記述することができます。

var TestClass2: {
    new (a: number, b: number): TestClass2;
    prototype: TestClass2;
} = <constructor-function data>;

※ 型名と変数名は同じ名前を用いることができます(classで記述する場合については両方が同時に作られるため、結果的に別途変数定義する際にはその名前が使えなくなります)。
※ 言語仕様のページでは上記のような例を挙げる際に「prototype」の記述はありません。これは「コンストラクター関数」としては「new()」のみで十分であるためと考えられますが、prototype を記述する方がより親切になります。

ここで、「コンストラクター関数」変数の「型」が「{ new (a: number, b: number): TestClass2; prototype: TestClass2; }」という内容で記述されていますが、これだけでは実体(値)がないため、<constructor-function data> にそれを与えてあげる必要があります。

そこで、この記述方法の場合コンストラクター関数のデータをどのように指定するかがポイントとなりますが、これは「class」(無名クラス)を利用することで以下のように記述することが可能です。

var TestClass2: {
    new (a: number, b: number): TestClass2;
    prototype: TestClass2;
} = class implements TestClass2 {
    data1: number;
    constructor(a: number, b: number) {
        this.data1 = a * b;
    }
    myMethod1(): void {
    }
};

※ 「class」と「implements」の間に適当な名前を挟むことができます(function と似ています)。スコープが異なるためか「TestClass2」としても名前の重複とならないようです。記述が冗長となっても良いのであれば名前を明示すると良いかもしれません。
※ 「class」の次が「implements」であるのは、型としての「TestClass2」はあくまで「interface」であるためです。
※ staticメンバがある場合は「コンストラクター関数」型にプロパティー宣言を追加し、さらに「class」ブロック内にstaticメンバを追加する必要があります。(後者のみだと外部から使用できません。)
※ この「代入」構文は「ECMAScript 2015 Language Specification」の仕様(ClassExpression)に基づきます(TypeScriptの構文はECMAScriptをベースとしているため)。

これにより、変数「TestClass2」は実体のある「コンストラクター関数」データをもつようになります。なお、このように記述した「TestClass2」はクラスとして扱われるため、new でインスタンスを作成できるだけでなく、他のクラスの基底クラスに指定することもできます。

classもどきのexport (結果)

さて、上記で「TestClass2」を直接「class TestClass2」と書かずに擬似的な(しかしほぼ本物の)classとして記述しましたが、本来「class」が作る2つの定義を分けて記述することによって、「コンストラクター関数」を(擬似的に)無効化することができます。これは、「var」の代入構文に条件演算子を用いることで可能になります。すなわち、

export interface TestClass2 {
    data1: number;
    myMethod1(): void;
}

export var TestClass2: {
    new (a: number, b: number): TestClass2;
    prototype: TestClass2;
} = __PROD__ ? null : class implements TestClass2 {
    data1: number;
    constructor(a: number, b: number) {
        this.data1 = a * b;
    }
    myMethod1(): void {
    }
};

という記述が有効になります。

※ 型としての「TestClass2」をexportしていることで「TestClass2」がprivateではなくなるため、TS4025 エラーが発生しなくなります。
※ 代入構文における「class」ではあくまでコンストラクター関数データ()の代入を行っているのみであるため、「privateな名前の外出し」(TS4025)には当たりません。

これにより、上記コードをビルドする際に「__PROD__」を true として最適化すると、コンストラクター関数データ以下が削除されるため、結果クラスの実体を最適化時に取り除くことができるようになります。(「TestClass2」変数自体は残りますが、null であるため使うと実行時エラーになります。)

なお、上記をJavaScriptに変換すると以下のようになります(module: "commonjs", target: "es5")。

(略)
exports.TestClass2 = __PROD__ ? null : (function () {
    function class_1(a, b) {
        this.data1 = a * b;
    }
    class_1.prototype.myMethod1 = function () {
    };
    return class_1;
}());
(略)