Programming Field

[TypeScript] 定数値を置き換えるtransformerを作った話

「enum 文を使わずに最適化可能な似非 Enum を作る」のページでは enum 文を使わずに最適化できる定数を扱う方法を記載していましたが、特定のパターンにおいては最適化されないことが分かったため、「型が数値または文字列のリテラル型 = 定数と分かっている変数やプロパティー」を実際の定数値に置き換えるというツールを書いてみました。

TL;DR

  • npm install -D ts-const-value-transformer でインストールし、「ts-loader 等でカスタムtransformerとして指定する」または「webpack ローダーの ts-const-value-transformer/loader を適用する」ことで使えます。(詳細は README.md を参照)
  • terser や swc などは既定では定数を自動で展開してくれますが、その定数が export されている場合は展開しないため、展開したい場合はこのtransformerが必要になります。

背景

「enum 文を使わずに最適化可能な似非 Enum を作る」にて enum 文を使わずに定数を記述する方法を紹介し、その方法で自作プロジェクト等で対応を行っていましたが、定数がエクスポートされる場合などでは最適化が効かないということに気づきました。

例として、以下のコードは定数(obj.a など)が最適化され値に展開されます。(swc の例はこちら)

const obj = {
  a: 1,
  b: 2,
  c: 3
};
console.log(obj.a, obj.b, obj.c);
export {};

ところが、以下のコードは定数が展開されず、少なくともswcの場合はほぼそのままの形で出力されます。(swc の例はこちら)

const obj = {
  a: 1,
  b: 2,
  c: 3
};
console.log(obj.a, obj.b, obj.c);
export { obj };

JavaScriptにおいては obj が本当の定数かどうかは判定できませんが、TypeScriptにおいて

const obj = {
  a: 1,
  b: 2,
  c: 3
} as const; // 不変である扱いにする
console.log(obj.a, obj.b, obj.c);
export { obj };

のように宣言した場合は、obj.a などはTypeScriptの世界においては「書き換え不可能」になるため(JavaScriptの世界では Object.freeze しない限り書き換え可能)、「定数」であることが明らかになります。

そのため、「TypeScriptの世界において定数と判定できるのであれば、TypeScriptにおいては安全に定数値に置き換えることができる」と考え、「ts-const-value-transformer」を作成しました。

ざっくりとした仕組みの説明

  • ts-const-value-transformer は、TypeScriptプロジェクトを読み込んで構文解析と型解析を行わせ、その後にソースコードについて「型が(unionなどではない)数値リテラルまたは文字列リテラルである変数またはプロパティー参照」を見つけたら、数値リテラル型または文字列リテラル型の指す実際の値に参照を置き換えてソースコードを再生成する、ということをしています。
    • ついでに、「enum 値の参照」についても置き換えを行います。
    • プロパティー参照についてはオプションで置き換えないようにすることも可能です(参照に副作用が含まれる可能性がある場合には有用です)が、通常プロパティー参照で副作用が入るのは(利用者側にとって)意図しない副作用が起こるかもしれないことには注意が必要です。
  • ただし、デフォルトでは「関数の戻り値が定数である関数の呼び出し」と「『as XXX』が含まれる参照」については定数への置き換えが安全ではない可能性があるため、置き換えを行いません。
    • オプションで置き換えるようにすることも可能です。

使い方

  • ts-loaderts-patch などではカスタムtransformerを指定するオプションが存在するため、ここに ts-const-value-transformer を指定します。
    • ts-loader の例:
      import { createTransformer } from 'ts-const-value-transformer';
      // または: const { createTransformer } = require('ts-const-value-transformer');
      // (「ts-const-value-tranformer」はESモジュールですが、同期処理のみのモジュールであるため、Node.js v20.19.0 以降では 'require' で読み込むことができます。参考: https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require)
      
      const yourWebpackConfiguration = {
        module: {
          rules: [
            {
              test: /\.ts$/,
              use: [
                {
                  loader: 'ts-loader',
                  options: {
                    getCustomTransformers: (program) => ({
                      before: [
                        createTransformer(program, {
                          options: {
                            /* 必要に応じて ts-const-value-transformer オプション */
                          },
                        }),
                      ],
                    }),
                    // ...(その他の設定)
                  },
                },
              ],
            },
          ],
        },
        // ...(その他の設定)
      };
    • ts-patch などで有効な tsconfig の例:
      {
        "compilerOptions": {
          "plugins": [
            {
              "transform": "ts-const-value-transformer/createTransformer",
              // オプションが必要な場合は「options」の中に記述
              "options": {}
            }
          ]
        }
      }
  • また、webpack loader として「ts-const-value-transformer/loader」を提供しており、babel-loader や swc-loader の前に適用することで定数を置き換えることができます。
  • APIとしても、「transformSource」(TypeScriptのSourceFileを変換)と「printSource」「printSourceWithMap」(変換後のSourceFileを文字列に変換)を提供しています。
  • オプションは以下の通りです。(v0.1.1 時点)
    • hoistProperty (既定値: true) - プロパティーが定数型の場合にその参照を置き換えるかどうかを指定します。明示的に false を指定することで置き換えないようにします。
    • unsafeHoistFunctionCall (既定値: false) - 関数の戻り値が定数型の場合にその関数呼び出しを置き換えるかどうかを指定します。明示的に true を指定すると置き換えが行われます。
    • unsafeHoistAsExpresion (既定値: false) - 定数型と判定された式の中に「as XXX」(型アサーション)が含まれる場合にその式を置き換えるかどうかを指定します。明示的に true を指定すると置き換えが行われます。

その他

TypeScriptの型情報があり、強く型付けができているのであれば、他の主要プログラミング言語と同様に最適化ができそうに考えています。