iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📛

The Trap of Nested Objects: RE: Grouping Options into Enum-like Constants in TypeScript

に公開3

This article was an interesting topic regarding static analysis and build size. I will write down my thoughts here.

https://zenn.dev/yumemi_inc/articles/ts-enum-like-const

Note: This is strictly an optimization from a build performance perspective. If you have a strong intention and believe that this is the best methodology for your domain modeling, I have no intention of stopping you from using the methodology in the original code.

What Happens When You Minify the Code from the Original Article

Based on the original code, I'll write some sample code to access it.

const sortingOptions = {
  priceDesc: {
    id: "priceDesc",
    sort: "price",
    order: "desc",
    label: "価格が高い順",
  },
  priceAsc: {
    id: "priceAsc",
    sort: "price",
    order: "asc",
    label: "価格が安い順",
  },
  ratingDesc: {
    id: "ratingDesc",
    sort: "rating",
    order: "desc",
    label: "平均評価が高い順",
  },
  ratingAsc: {
    id: "ratingAsc",
    sort: "rating",
    order: "asc",
    label: "平均評価が低い順",
  },
}

const labels = [
  sortingOptions.priceDesc.label,
  sortingOptions.priceAsc.label,
  sortingOptions.ratingDesc.label,
  sortingOptions.ratingAsc.label,
];

console.log(labels);

Since types do not remain after minification, I've removed them to make it easier to test in the Terser playground.

For those who want to try it themselves: https://try.terser.org/

Result

const e=[{id:"priceDesc",sort:"price",order:"desc",label:"価格が高い順"}.label,{id:"priceAsc",sort:"price",order:"asc",label:"価格が安い順"}.label,{id:"ratingDesc",sort:"rating",order:"desc",label:"平均評価が高い順"}.label,{id:"ratingAsc",sort:"rating",order:"asc",label:"平均評価が低い順"}.label];console.log(e);

The object remains in its entirety, and by accessing only a part of it, it mostly becomes dead code at runtime.

There is a way to eliminate this.

Optimizable Code

This is just an example of how I would write it.

const enum SortingOptions$Labels {
  priceDesc = "価格が高い順",
  priceAsc = "価格が安い順",
  ratingDesc = "平均評価が高い順",
  ratingAsc = "平均評価が低い順",
};
const enum SortingOptions$Order {
  desc = "desc",
  asc = "asc",
}
const enum SortingOptions$SortType {
  price = "price",
  rating = "rating",
}

const labels = [
  SortingOptions$Labels.priceDesc,
  SortingOptions$Labels.priceAsc,
  SortingOptions$Labels.ratingDesc,
  SortingOptions$Labels.ratingAsc
];
console.log(labels);

I’m using $ as a namespace-like separator, but that’s a matter of preference. If you don’t want to use enums, you can expand and flatten everything, like export const SortingOptions$Labels$priceDesc = ....

tsc + terser converts it as follows:

console.log(["価格が高い順","価格が安い順","平均評価が高い順","平均評価が低い順"]);

The object definition is completely gone, and only the constants have been inlined.

...I admit that I conducted a benchmark that was favorable to my case. However, what I want to convey is that while the original code leaves no room for static analysis to trim the build size, the revised code allows for the removal of unused code.

This can emerge as an issue when a project handles massive configuration values. For example, I have actually seen performance degrade in a scenario where a huge JSON schema for server-side validation was converted into nested objects by a custom converter for client-side use.

Namespace-like Re-export Anti-pattern

To be honest, the above case is a trivial issue in most situations. If you tell me that's how your modeling works, I won't stop you. Though I would mention that it's disadvantageous in terms of the build.

The problem arises when you do this with the export default {...} pattern. While this isn't an issue the original article fell into, I'll bring it up as a related problem to help explain the points above.

I often see code like this:

import a from "./a";
import b from "./b";
export default {a, b}

It creates a temporary object as a default namespace and registers values or functions as its members. At first glance, this looks like code that has been reorganized by repackaging similar objects.

However, from a static analysis perspective, a problem arises: what the importing side accesses via the default object's getter cannot be resolved statically at import time.

import ns from "./api";
console.log(Object.keys(ns));

I arbitrarily call this the "Namespace-like Re-export Anti-pattern." Unfortunately, in minifiers like Terser, usage at the level of object member access is outside the scope of optimization. This is because the possibility of a bypass via a getter cannot be ruled out at the language specification level.

export default {
  x: 1,
  get y(){
    return this.x + 1;
  }
}

...Is this a minor issue?

Based on my experience, I believe this is exactly what causes major problems. export default {..} removes items from the scope of tree shaking by repackaging everything into a temporary object. As a result, everything ends up being included in the build chunk.

If it were just frontend performance, it might be tolerable. But in environments like Next.js where modules are shared between client and server, simple code like the following can lead to security incidents.

// packages/shared/constants.ts
const CLIENT_ID = 1;
const SERVER_SECRET_TOKEN = "secret-id!!!";
// All values are always exposed at the import destination.
export default { CLIENT_ID, SERVER_SECRET_TOKEN }

Personally, I believe that we should not bring subjective preferences into API styles; instead, we should measure them using quantified metrics such as build size, coverage, and runtime cost.

My Conclusion

Some time ago, I investigated cases where running Terser multiple times would reduce the code size. I'll quote the results from that time here.

https://zenn.dev/mizchi/articles/terser-many-times

The best practice derived from this is not to "just run Terser multiple times," but rather that "constant declarations should not be done within object members." It's unnecessary trouble for Terser.

Discussion

Honey32Honey32

ありがとうございます!

言及いただいた記事とは別なのですが、以下の記事の内容(Enum 的なほうでなく、Config 的なほう)は、相互変換を伴わないのでインライン化の恩恵が大きそうで、にもかかわらず minify と tree-shaking を妨げていて、特に酷いことになっていることに気づきました…

https://zenn.dev/yumemi_inc/articles/js-front-constants-a1fb3c49eb1199

一旦は該当記事にコールアウトを追加して当記事へのリンクを示し、追って調査・修正します。

mizchimizchi

@Honey32

該当記事を読んだ感じ、特に避けたほうが良いのは次のコードだと思いました。

export const exportedFileNames = {
  userPosts: (userName: string) => `投稿一覧_${userName}さま.json`,
  everything: `この世の全て.json`,
} as const

これは userPosts がアロー関数かそうじゃないか (thisへのアクセス可能性を含むか)、everything の多層ネスト解決時に何回minify を掛けたかで結果が変化します。特に必要性のない複雑性を持ち込むので、ラップしたオブジェクトのネームスペースで区切るより、ファイルスコープやその他の手段で分割したいケースだと思います。

Honey32Honey32

アローじゃない関数の件、全く知りませんでした…

こういう細かな知識を要求されてしまうことを考えると、

exportedFileNames_userPosts のように、「ドル記号なりアンダースコアなりを使って擬似的な名前空間を作る」に寄せてしまったほうがのが、(自動補完の効きやすさとの兼ね合いも含めて)やりやすそうですね…

よぎったことはある気がしますが「でも、こんな変な書き方、癖が強くて受け入れられにくそうやな…」と思って却下していましたが、思い切って導入しようと思います。

一応、Next.js 14.2.5 App Router で試してみると、アローじゃない関数や、pagintaion["/download/"].itemsPerPage でも上手くインライン化されてしまい、困惑しています…

でも、Next.js 14.1.4 Pages Router のプロジェクトでネストの方を試すとインライン化に失敗していたのを確認できたので、最適化機能の「気を利かせた」挙動に依存しきった富豪的な方法よりも「手動名前空間」を、より安全ということで優先的に扱おうと思います。

アローじゃない関数で実際にやってみた
app/_consts/exported-file-names
export const exportedFileNames = {
  userPosts(userName: string) {
    return `投稿一覧_${userName}さま.json`;
  },
  everything: `この世の全て.json`,
} as const;

app/download/page.tsx
"use client";

import { FC } from "react";
import { exportedFileNames } from "../_consts/exported-file-names";

const DownloadPage: FC = () => {
  return (
    <div>
      <div className="container mx-auto p-4">
        <h1 className="text-4xl font-bold px-4">ダウンロード</h1>

        <button
          className="p-2 border-2 bg-slate-100 hover:bg-slate-200 rounded cursor-pointer"
          onClick={() => {
            console.log(`ダウンロード ${exportedFileNames.everything}`);
          }}
        >
          ダウンロード
        </button>
      </div>
    </div>
  );
};

export default DownloadPage;

/_next/static/chunks/app/download/page-a836fb35e742ef4d.js
(self.webpackChunk_N_E = self.webpackChunk_N_E || []).push([[40], {
  2439: function(n, e, s) {
    Promise.resolve().then(s.bind(s, 8896))
  },
  8896: function(n, e, s) {
    "use strict";
    s.r(e),
    s.d(e, {
      default: function() {
        return t
      }
    });
    var o = s(7437)
      , t = ()=>(0,
    o.jsx)("div", {
      children: (0,
      o.jsxs)("div", {
        className: "container mx-auto p-4",
        children: [(0,
        o.jsx)("h1", {
          className: "text-4xl font-bold px-4",
          children: "ダウンロード"
        }), (0,
        o.jsx)("button", {
          className: "p-2 border-2 bg-slate-100 hover:bg-slate-200 rounded cursor-pointer",
          onClick: ()=>{
            console.log("ダウンロード ".concat("この世の全て.json"))
          }
          ,
          children: "ダウンロード"
        })]
      })
    })
  }
}, function(n) {
  n.O(0, [971, 23, 744], function() {
    return n(n.s = 2439)
  }),
  _N_E = n.O()
}
]);