iTranslated by AI
The Trap of Nested Objects: RE: Grouping Options into Enum-like Constants in TypeScript
This article was an interesting topic regarding static analysis and build size. I will write down my thoughts here.
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.
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
ありがとうございます!
言及いただいた記事とは別なのですが、以下の記事の内容(Enum 的なほうでなく、Config 的なほう)は、相互変換を伴わないのでインライン化の恩恵が大きそうで、にもかかわらず minify と tree-shaking を妨げていて、特に酷いことになっていることに気づきました…
一旦は該当記事にコールアウトを追加して当記事へのリンクを示し、追って調査・修正します。
@Honey32
該当記事を読んだ感じ、特に避けたほうが良いのは次のコードだと思いました。
これは userPosts がアロー関数かそうじゃないか (thisへのアクセス可能性を含むか)、everything の多層ネスト解決時に何回minify を掛けたかで結果が変化します。特に必要性のない複雑性を持ち込むので、ラップしたオブジェクトのネームスペースで区切るより、ファイルスコープやその他の手段で分割したいケースだと思います。
アローじゃない関数の件、全く知りませんでした…
こういう細かな知識を要求されてしまうことを考えると、
exportedFileNames_userPostsのように、「ドル記号なりアンダースコアなりを使って擬似的な名前空間を作る」に寄せてしまったほうがのが、(自動補完の効きやすさとの兼ね合いも含めて)やりやすそうですね…よぎったことはある気がしますが「でも、こんな変な書き方、癖が強くて受け入れられにくそうやな…」と思って却下していましたが、思い切って導入しようと思います。
一応、Next.js 14.2.5 App Router で試してみると、アローじゃない関数や、
pagintaion["/download/"].itemsPerPageでも上手くインライン化されてしまい、困惑しています…でも、Next.js 14.1.4 Pages Router のプロジェクトでネストの方を試すとインライン化に失敗していたのを確認できたので、最適化機能の「気を利かせた」挙動に依存しきった富豪的な方法よりも「手動名前空間」を、より安全ということで優先的に扱おうと思います。
アローじゃない関数で実際にやってみた