🎐

packelyze - お前の TypeScript はもっと小さくなる

2023/05/25に公開
2

TypeScriptの型定義ファイルから積極的な圧縮を行うための @mizchi/optools をリリースした。まだ実験中だが、結構動くはず。使う場合は自己責任で。

追記: optools を packelyze に rename した。これは optools という CLI 名が ImageMagick の提供するコマンドとぶつかったため。

https://github.com/mizchi/packelyze/tree/main/cli

試行錯誤の過程は https://zenn.dev/mizchi/scraps/1bdf01f5efb147 にある。

このライブラリは、自分の所属する Plaid の業務時間中に作成した。

想定ユーザー

  • ライブラリ作者
  • ビルドサイズ厳しいフロントエンド開発者(サードパーティスクリプト等。自分が業務で作った理由がここ)
  • リスクとってでもビルドサイズを縮めたいフロントエンド作者

動機

世の中な TypeScript で書かれたコードは、その型情報を使えば本来もっと圧縮できるはずなのに、 terser は素の JS をターゲットにしているため、オブジェクトアクセスやダイナミックな書き換えを想定し、積極的な圧縮が行えていない。

例えば次のようなコードが圧縮時に最悪の振る舞いをする。

class InnerClass {
  publicMethod() {
    this.privateMethod();
    return this;
  }
  privateMethod() {}
}

export class MyClass {
  value = 1;
  hello() {
    new InnerClass().publicMethod().publicMethod().publicMethod();
    return this; 
  }
}

const myClass = new MyClass();
console.log(myClass.hello().value);

これを minify するとこうなる

class e{publicMethod(){return this.privateMethod(),this}privateMethod(){}}export class MyClass{value=1;hello(){return(new e).publicMethod().publicMethod().publicMethod(),this}}const t=new MyClass;console.log(t.publicMethod().value);

本来、MyClasshello だけ残して、全部変数は一文字に(mangle)できるはず。つまりこうなってほしい。

class s{o(){return this.p(),this}p(){}}export class MyClass{value=1;hello(){return(new s).o().o().o(),this}}const l=new MyClass;console.log(l.hello().value);

つまり、これをやるライブラリを書いた。

使い方

$ npm install @mizchi/optools -D 

または npx で直接使ってもよい。(例 $ npx @mizchi/optools analyze-dts -i lib/index.d.ts -o _analyzed.json)

optools を使うために、事前に自分のコードベースの型定義ファイル(.d.ts)を生成しておく。

lib/*.d.ts を生成する tsconfig.json の例

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "lib",
    "declaration": true,
    "emitDeclarationOnly": true,
  }
}

これを tsc -p . などすると型が吐き出される。

そのコードベースのエントリポイントの型定義ファイル(lib/index.d.ts など)を入力として、 解析結果を _analyzed.json として吐き出す。

$ npx @mizchi/optools analyze-dts -i lib/index.d.ts -o _analyzed.json

この解析結果の _analyzed.json を使って、 terser の mangle.properties.reserved にわたす。その際、regex に最も積極的なオプション .* を渡す。つまり、 analyzed.reserved 以外はすべて mangle 対象となる。

vite.config.ts
import { defineConfig } from "vite";
import analyzed from "./_analyzed.json";

export default defineConfig({
  build: {
    // use terser: because esbuild does not support mangle properties builtins
    minify: "terser",
    terserOptions: {
      mangle: {
        properties: {
          regex: /.*/,
          reserved: analyzed.reserved,
        },
      },
    },
  },
});

残念ながら esbuild --minify では使えなかった。terser は内部で持ってる辞書で builtins なプロパティを尊重するが、 esbuild は regex を満たすとそれを潰してしまうため。swc はまだ確認してない。

これによって export された公開インターフェース以外が圧縮対象になり、コードサイズがグッと減る。

このエントリポイントの型定義を入力に与えるインターフェース、おそらくライブラリでない場合は何も export されてないので無意味に見えるかもしれないが、後述する外部の副作用の宣言にも使える。

注意点: 外部への副作用を宣言する

ESM の入出力で表現される範囲ではこれだけで十分なのだが、これだけでは外部への副作用、例えば fetch()postMessage() に渡すオブジェクトの内部のキーも mangle 対象としてしまう。これによって, {keepMe: 1} みたいなコードが {o:1} となってサーバーに送られてしまう可能性がある。

(本筋からそれるが、ここまで来ると一部の関数型言語が持ってる Effect System がほしくなる。が、TypeScript にそれを実装するのは並大抵の努力では済まない)

よって、 エントリポイントの src/index.ts などで、それらの型を export type などして optools に教える必要がある

src/index.ts
export type __Effects =
  | MySendBodyType
  | MySendResponseType
  // ... others
  ;

もし openapi などによって型定義を出力してるならば、それを export type * from "./types" で終わるだろう。

で、自分は最近、 @mizchi/zero-runtime というライブラリを作っていて、生の fetch に型を宣言できるようにした。

import type { TypedFetch, JSON$stringifyT, JSON$parseT } from "@mizchi/zero-runtime";

const stringifyT = JSON.stringify as JSON$stringifyT;

const fetch = window.fetch as TypedFetch<{
  // Self
  "": {
    "/api/:xxx": {
      methodType: "GET";
      bodyType: { text: string; number: number; boolean: boolean };
      headersType: { "Content-Type": "application/json" };
      responseType: { text: string; number: number; boolean: boolean };
    } | {
      methodType: "POST";
      bodyType: { postData: number };
      headersType: { "Content-Type": "application/json" };
      responseType: { ok: boolean };
    };
    "/api/nested/:pid": {
      methodType: "GET";
      bodyType: { nested: 1 };
      headersType: { "Content-Type": "application/json" };
      responseType: { text: string; number: number; boolean: boolean };
    };
    "/search": {
      methodType: "GET";
      headersType: { "Content-Type": "application/json" };
      responseType: { text: string };
      // NOTE: Not handle as type yet.
      searchType: { q: string }
    },
  },
  // with host
  "https://z.test": {
    "/send": {
      methodType: "POST";
      bodyType: { text: string; };
      headersType: { "Content-Type": "application/json" };
      responseType: { ok: boolean };
    };
  };
}>;
const res = await fetchT("/api/xxxeuoau", {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
  },
  body: stringifyT({ text: "text", number: 1, boolean: true }),
});

そして https://github.com/mizchi/lizod というバリデータを作った。これは @mizchi/zero-runtimeTypedFetch と組み合わせて使う想定で、バリデータと型を用意して、型付き fetch と一緒に使うと、自然と optools を安全に使うためのパーツが自然と揃う。

https://zenn.dev/mizchi/articles/lizod-is-lightweight-zod

勿論、これが本当に安全に使えるかは検証が必要で、まだ production ready とは言えない。 TypeScript の型安全でない操作をしている箇所があったり、そもそも eval()new Function() を内部で使うと、その安全性はかなり厳しいと言わざるを得ない。なので、はっきり言ってしまうと、コード内部で発生する副作用やリフレクションを事前に想定できないと、まともに使えないツールではある。

benchmark

適当なライブラリを pick して optools の有無で minify 後のサイズを比較した。

https://github.com/mizchi/optools/tree/main/examples/benchmark

23K hono-usage.js
19K hono.js
45K react-library.js
52K zod-usage.js
2.8M May 25 20:57 typescript.js
terser with optools

21K hono-usage.js
17K hono.js
41K react-library.js
43K zod-usage.js
2.1M typescript.js

typescript が最大 75% ほどに小さくなっている。これは内部で外に露出しない namespace を大量に持っていて、それが縮んだからだと考えられる。

mangle しない方がハフマン符号化によって圧縮されやすいはずなので gzip 後の差を見てみる。

805K typescript.js.gz
700K typescript.js.gz

これでもまだ 100kb 近い差になった。optools は効くと言えそう。

こんなんアグレッシブな圧縮が使えるわけ無いだろ!! 安全性をどうにか確認させろ!

というわけで vitest で snapshot テスト(パターン)を作った。

// vite.config.ts
import { defineConfig } from "vite";
import analyzed from "./_analyzed.json";
import { minify } from "terser";

export default defineConfig({
  plugins: [
    {
      name: "optools safety check",
      enforce: "post",
      async transform(code, id) {
        if (!process.env.OPTOOLS_CHECK) return;
        if (id.endsWith(".js") || id.endsWith('.ts' || id.endsWith('.tsx'))) {
          const result = await minify(code, {
            compress: false
            mangle: {
              module: true,
              properties: {
                regex: /^.*$/,
                reserved: analyzed.reserved,
              },
            },
          });
          return result.code;
        }
      },
    },
  ],
});

もし OPTOOL_CHECK が true なら、個別のファイル読み込みに対してこの minifier がかかる。(有効時、全ファイルに terser を掛けるのでそれなりに重そう)

これに対して内部オブジェクトを Snapshot が一致するかテストする。

src/fetch.test.ts
import { expect, test } from "vitest";
import type { JSON$stringifyT } from "@mizchi/zero-runtime";

const stringifyT = JSON.stringify as JSON$stringifyT;

test("keep send body", async () => {
  // なにかしらコードベース内で大きいオブジェクトを作る
  const body = stringifyT({ keepMe: "hello" });
  expect(body).toMatchSnapshot();
});

これを環境変数を変えて2回走らせる

$ pnpm vitest --run # it creates __snapshot__
$ OPTOOLS_CHECK=1 pnpm vitest --run # should match with result with mangling

snapshot が一致すれば minify されてないことを保証できる。

内部実装

このセクションは中身を知りたい人向け。

調査過程で rollup-plugin-dts という rollup のプラグインを見つけた。これはよくよく見るとただの rollup plugin ではなく、TypeScript 本体がやってくれない、型定義ファイルを一つにまとめる処理をやってくれる。作者はこれを TypeScript 本体がやってくれないことに不満を持っているようだった。

https://github.com/Swatinem/rollup-plugin-dts#why
https://github.com/Microsoft/TypeScript/issues/4433

すごいことに、この rollup-plugin-dts は rollup の treeshake 機能を使って(るのかは正確には追ってないが多分)、export されない型定義を落としてくれる。つまり、これで最終的に折りたたまれた型定義一つを解析すれば、最終的なインターフェースが残る、というわけ。

https://github.com/mizchi/optools/tree/main/examples/playground を解析すると、次のような型定義ファイルが出てくる。 --printDts で出力できる。

$ pnpm tsc -p . # emit
$ npx @mizchi/optools analyze-dts -i lib/index.d.ts --printDts
// bundled.d.ts
type Output = {
    result: {
        value: number;
    };
};
type PubType = {
    pubTypeKey: number;
};
interface InterfaceType {
    ikey: number;
}
declare function sub(opts: {
    xxx: number;
    yyy: string;
}): {
    value: number;
};
declare function effect(str: string): Promise<Response>;
declare class PublicClass {
    #private;
    "A-B"(): void;
    publicMethod(): Output;
    private privateMethod;
    private privateMethod2;
    private privateMethod3;
    private privateMethod4;
}
declare module PublicModule {
    module nested {
        const y = 1;
    }
    interface PublicModuleInterface {
    }
    class PublicModuleClass {
        publicModuleClassMethod(): Output;
        private privateModuleClassMethod;
    }
    type PublicModuleType = {
        pubModType: number;
    };
    const pubModConst = 1;
}

export { InterfaceType, PubType, PublicClass, PublicModule, effect, sub };

これらの型定義を解析して TypeLiteral の key 部分や class の public プロパティだけ取り出すのを自分で実装した。

https://github.com/mizchi/optools/blob/main/cli/src/analyzer.mts#L8-L155

optools はかなりの部分をこの rollup-plugin-dts に助けられていて、型定義ファイルを一つにバンドルする過程で、公開インターフェースだけ残す、というのをこのライブラリがすでにやってくれている。

自分が実装したのは、一つにまとめられた .d.ts の AST をなぞって、公開インターフェースを切り出すところだけだった。

これを知ってれば、使う側のコツとして、公開インターフェースを小さくすればするほど圧縮効率が高まるのがわかると思う。

今後

正直、最初に作りたかったものからすると、とりあえずそれっぽいキーを列挙するだけで、何の呼び出し元かを区別できず、偽陽性まみれで妥協の産物になった。それでもかなり効果が高いものを実装できたので、アプローチは間違ってなかったと思う。

試行錯誤のスクラップに残っているが、本当は型定義ファイルではなく、入力されたすべての TypeScript のソースコードの、あらゆるレキシカルスコープを解析してデッドコードの追跡をしようとしていた。これをやろうとすると実装コストも実行コストもおそらく半端なく、一旦諦めた。最悪 Rust で書き直せと言われるところまで見える。

今後は一旦それに取り組んでみて、根本的に何ができるかもう少し調査してみようと思う。

Discussion

ムニエルムニエル

最初のコードブロックのmyClass.publicMethod()は、myClass.hello()の間違いですかね?