Executive conclusion
For the stated “single word” convention, the recommended path is:
Named exports in the API module, namespace imports at call sites where domain collisions are possible.
In the tested static-access case:
A and B produce the same tree-shaken output.
C keeps the complete object and all its methods.
D keeps the complete class and all its static methods.
Zstd can compress repeated text, but it does not restore tree-shaking and does not remove JavaScript parsing, compilation, or execution costs.
Therefore, B is the best default for the stated naming goal:
// @/api/domain.js
export async function get() {}
export async function create() {}
export async function remove() {}// @/composable/useDomain.js
import * as domain from '@/api/domain';
const domainArray = await domain.get();Environment and version scope
The conclusion was checked in two layers rather than attributing a Rollup result directly to every Vite version.
Layer | Version/environment | Purpose
Direct bundler test | Node.js v22.13.0, Rollup 4.63.3, esbuild 0.28.2 | Isolate module-shape and tree-shaking behavior
Vite production test | Node.js v22.13.0, Vite 8.3.0, Rolldown 1.2.9, esbuild 0.28.2, Linux x64 | Verify the result through the current Vite production pipeline
Compression test | Zstandard CLI 1.5.5, level 19; gzip 1.12, level 9 | Compare transfer-size behavior only
Vite 8 is important here: according to the official Vite 8 announcement, Vite 8 uses Rolldown as its unified production bundler instead of the older Rollup production path. The Vite 8 build was executed with vite build, with sourcemaps disabled and esbuild minification selected explicitly so that the A–D comparison was not mixed with an unrelated minifier change.
Vite 8 requires Node.js 20.19+ or 22.12+; the tested Node.js 22.13.0 satisfies that requirement. A different Node.js version can change parser, transformer, or minifier implementation details, but it should not change the fundamental distinction between ES module bindings and a runtime object/class. Exact byte counts are version- and configuration-specific and must not be treated as universal constants.
A remains a valid local shorthand when no name collision exists. C and D should not be selected as API-organization techniques for the purpose of improving tree-shaking.
A–D comparison
Scheme | Export shape | Static call get() | Tested tree-shaking | Naming fit | Recommendation
A | Named exports + named imports | get() | Excellent | Weak when domains collide | Use locally when collision-free
B | Named exports + namespace import | domain.get() | Same as A in the tested case | Excellent | Default choice
C | Default object literal | domain.get() | Poorer; unused object members remain | Good | Avoid for this goal
D | Default class with static methods | Domain.get() | Poorer; unused static methods remain | Acceptable but verbose | Avoid for this goal
Reproduction experiment
The experiment used Rollup 4.63.3 with tree-shaking enabled. Each module defined get, create, and remove; the entry point called only get. The generated JavaScript was then minified by esbuild 0.28.2 and compressed with Zstd 1.5.5 at level 19.
Generated output shape
A produced only the used function:
const payload = 'domain-payload';
async function get() { return payload + ':get'; }
console.log(await get());B produced the same effective output. The namespace object did not force all named exports into the final bundle because the property access was statically known as domain.get.
C produced the full object:
var domain = {
async get() { return payload + ':get'; },
async create() { return payload + ':create'; },
async remove() { return payload + ':remove'; },
};
console.log(await domain.get());D produced the full class:
class Domain {
static async get() { return payload + ':get'; }
static async create() { return payload + ':create'; }
static async remove() { return payload + ':remove'; }
}
console.log(await Domain.get());Measured sizes
These numbers are intentionally small and are useful for comparing shape, not for predicting a whole application’s absolute size.
Scheme | Rollup output | Minified output | Zstd output
A | 111 B | 84 B | 90 B
B | 111 B | 84 B | 90 B
C | 230 B | 160 B | 120 B
D | 247 B | 179 B | 120 B
The compressed C/D outputs were smaller than their minified inputs because compression has fixed framing and dictionary effects, but they were still larger than A/B. More importantly, C/D carried unused code that A/B removed before compression.
Vite 8/Rolldown production build
The same four variants were built through vite@8.3.0 using its Rolldown production pipeline. The generated JavaScript included Vite's module-preload runtime, so the absolute numbers are larger than the isolated Rollup numbers, but the relative result was unchanged:
Scheme | Vite output | gzip-9 | Zstd-19
A | 792 B | 466 B | 452 B
B | 792 B | 466 B | 452 B
C | 864 B | 489 B | 474 B
D | 888 B | 496 B | 483 B
The Vite 8 output for A and B was byte-for-byte identical in this test. C retained get, create, and remove in its object, while D retained all three static class methods. Thus the conclusion was not an artifact of testing only an older Rollup pipeline.
Collision scenario
The experiment also bundled two domains, both exposing a get function.
B:
import * as domain from '@/api/domain';
import * as account from '@/api/account';
await domain.get();
await account.get();A with aliases:
import { get as domainGet } from '@/api/domain';
import { get as accountGet } from '@/api/account';
await domainGet();
await accountGet();Both generated the same effective bundle in the test:
Collision form | Rollup output | Minified output | Zstd output
B: namespace imports | 218 B | 155 B | 111 B
A: aliased named imports | 218 B | 155 B | 111 B
The aliases are not a tree-shaking problem; they are a naming and readability problem. B solves that problem without a measured bundle penalty.
What Zstd changes—and what it does not
Zstd is a transfer/storage compression layer. It can exploit repeated tokens, identifiers, method syntax, and similar code across modules. This may reduce the relative penalty of C or D in compressed bytes, especially in a larger bundle with repeated structures.
However, compression does not change the following facts:
- 1.
Tree-shaking happens before compression. If C/D retain unused code, Zstd receives that code as input.
- 2.
The browser must decompress and process the resulting JavaScript. A smaller compressed transfer does not imply a smaller JavaScript parse/compile/execute workload.
- 3.
Repeated code is not free at runtime. A compressed copy may be cheap over the network but still costs uncompressed bytes in memory and processing.
- 4.
A and B also benefit from compression. Zstd’s dictionary is not an advantage exclusive to object or class wrappers.
- 5.
Compression results depend on chunk boundaries. Separate chunks, caching, and content encoding settings can materially change the result; a single synthetic bundle cannot establish a universal winner.
As web.dev summarizes, compression affects transfer size, while the browser still processes the decompressed JavaScript. Thus compression is a secondary optimization and should not be used to justify a less tree-shakeable module shape.
Caveats and boundaries
Tree-shaking is a build-time optimization, not an ECMAScript guarantee. Results can change when:
a namespace is passed around, spread, enumerated, dynamically indexed, or accessed through an unknown key;
an object or class identity is observable or mutated;
static class fields, decorators, getters, computed properties, or module initialization have side effects;
a dependency is external, CommonJS, or marked as having side effects;
library output intentionally preserves modules or exports for consumers;
a different bundler, plugin, minifier, target, or output format is used.
For example, these patterns make pruning harder or impossible:
const method = domain[userInput];
Object.keys(domain);
use(domain);
const copy = { ...domain };The async keyword does not materially change the export-shape conclusion.
Recommended convention
Use domain vocabulary as the exported word and use the module namespace to provide context:
// @/api/domain.js
export async function get() {}
export async function create() {}
export async function remove() {}// @/composable/useDomain.js
import * as domain from '@/api/domain';
import * as account from '@/api/account';
domain.get();
account.get();Use A only when the local scope has no collision:
import { get, create, remove } from '@/api/domain';Avoid C and D when the primary objective is independently removable API members. They may be reasonable for other reasons—such as a deliberate object-oriented runtime abstraction, a stable object identity, or a class-based extension point—but those are separate design goals and should be evaluated explicitly.
Sources
- 1.
Vite — Building for Production: current Vite production builds expose the underlying Rolldown build options; older Vite versions used Rollup for this phase.
- 2.
Rollup — Configuration Options:
treeshake: tree-shaking is enabled by default and property-read/module-side-effect behavior can be configured. - 3.
MDN —
import: Module namespace object:import * as nameproduces a module namespace object representing module exports. - 4.
web.dev — Reduce JavaScript payloads with tree shaking: compressed transfer size does not eliminate the processing cost of decompressed JavaScript.
- 5.
Vite 8.0 is out: Vite 8 uses Rolldown as its unified bundler and documents its Node.js support range.
- 6.