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. 1.

    Tree-shaking happens before compression. If C/D retain unused code, Zstd receives that code as input.

  2. 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. 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. 4.

    A and B also benefit from compression. Zstd’s dictionary is not an advantage exclusive to object or class wrappers.

  5. 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.

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. 1.

    Vite — Building for Production: current Vite production builds expose the underlying Rolldown build options; older Vite versions used Rollup for this phase.

  2. 2.

    Rollup — Configuration Options: treeshake: tree-shaking is enabled by default and property-read/module-side-effect behavior can be configured.

  3. 3.

    MDN — import: Module namespace object: import * as name produces a module namespace object representing module exports.

  4. 4.

    web.dev — Reduce JavaScript payloads with tree shaking: compressed transfer size does not eliminate the processing cost of decompressed JavaScript.

  5. 5.

    Vite 8.0 is out: Vite 8 uses Rolldown as its unified bundler and documents its Node.js support range.

  6. 6.