skip to content

In a bundled ES-module app, does writing `import * as utils from './utils.js'` and calling only `utils.formatDate(d)` cost more bytes than `import { formatDate } from './utils.js'`?

level: juniorimportance: should knowfreq 45%

answer

  1. static property read is analysable
  2. the object must not escape
  3. spread, dynamic key, pass-through
  4. named imports stay analysable regardless
  5. syntax cannot rescue CommonJS

basics

~20 s

Usually not: a modern bundler can see that only formatDate is read off the namespace and drops the rest. It costs bytes the moment the namespace object escapes — passed to a function, spread, or indexed with a variable — because then every export must be kept.

solid answer

~50 s

For plain ES modules, modern bundlers treat `utils.formatDate` as a static reference and can eliminate the other exports, so the two forms usually cost the same. The difference appears when the namespace object stops being a compile-time convenience and becomes a real object: passing `utils` into a function, spreading it, re-exporting it wholesale, or indexing it dynamically with `utils[name]()`. Any of those means the build must assume every export is reachable and keeps all of them. There is also a practical argument beyond bytes: named imports make it obvious at the top of the file exactly what this module depends on, which is what a reviewer and a size check both want. So I default to named imports — not because the namespace form is always more expensive, but because it is the form that stops being analysable first.

code

javascript · 10 lines
javascript
import * as utils from './utils.js';

// Analysable: a fixed property read, other exports can be dropped
const a = utils.formatDate(new Date());

// Not analysable: the namespace object escapes, everything is retained
function register(mod) { return Object.keys(mod); }
const b = register(utils);

export { a, b };

go deeper

for a junior

Default to named imports and be able to say why in one line: they state exactly what the file uses and stay analysable. Do not claim the namespace form is always more expensive.

for a middle

Explain that a fixed property read on a namespace is statically analysable, and name the cases that break it — passing the object to a function, spreading it, computed keys, wholesale re-export.

for a senior

Frame it as a robustness argument rather than a byte argument: the named form cannot silently regress, and note that neither form helps when the imported module is CommonJS.

for a principal

Decide whether this is worth a lint rule at all. Mechanical import-style rules cost review attention; spend it where the size risk is real, such as banning root imports of known monolithic packages.

## What the two forms actually mean ```javascript import { formatDate } from './utils.js'; // named import import * as utils from './utils.js'; // namespace import ``` A named import binds one exported name into the importing module. A namespace import binds a single object whose properties are the module's exports. Both are static declarations — the module path is a string literal, the bindings are fixed before anything runs — which is the property that makes a build able to reason about them at all. ## Why the namespace form is usually free When the only thing you ever do with the namespace is read a known property, as in `utils.formatDate(d)`, the build can rewrite that access to a direct reference to the one export. From there the analysis is identical to the named-import case: every other export has no reader, so it can be dropped. Bundlers have done this for years, which is why the honest answer to the interview question is "usually no difference" rather than the folk rule "namespace imports break tree shaking." The folk rule persists because it used to be closer to true, and because it is right in the cases that matter most. ## When it stops being free The analysis holds only while the namespace object never has to exist as a real value. Once it does, the build has to assume any property might be read, and it keeps them all: ```javascript import * as utils from './utils.js'; register(utils); // the object escapes into a function const all = { ...utils }; // spread materialises every export utils[pickName()](); // dynamic key: no known property export * from './utils.js'; // wholesale re-export from a barrel ``` Each of these is a genuinely different situation from a fixed property read. `register(utils)` could store the object and read anything from it later; a computed key could be any string. The build has no way to bound what is reachable, so it stops trying. Note that the last line is not even a namespace import — it is the same failure mode arriving through a re-export. ## The CommonJS caveat All of this assumes both sides are ES modules. If `./utils.js` is really a CommonJS module, or a package's CommonJS build, the exports are properties of an object assembled at runtime, and neither import form gives the build anything to narrow. The import syntax you write cannot make an opaque module transparent. ## So why still prefer named imports Three reasons that survive the "usually no difference" answer: 1. **Robustness.** Named imports keep working as analysable references no matter what the rest of the file does. A namespace binding is one careless `register(utils)` away from retaining everything, and nothing in review flags that as a size change. 2. **Legibility.** The import block becomes an accurate list of what this file depends on. That is what a reviewer scans, and it is what makes an unexpected heavy dependency visible at the top of the file rather than buried in a call. 3. **Consistency with the package case.** For third-party packages, `import * as _ from 'lodash'` and `import { debounce } from 'lodash'` cost about the same and both cost a lot — but the named form at least states the intent, and makes a deep import an obvious next step. ## The answer to give Say that for ES modules the two forms usually cost the same because a fixed property read is statically analysable; name the escape cases that break it; note that neither form helps against a CommonJS module; and finish with the practical default. That is a more credible answer than reciting "namespace imports prevent tree shaking," which an interviewer who knows the area will correct.

  • Does the same reasoning apply to `export * from './utils.js'` in a barrel file?
    Not in the same way. A wholesale re-export forwards every binding, so anything importing through that file drags the whole set into the graph unless the build can prove otherwise. It is the same failure shape — the module surface becomes opaque — arriving through re-exports rather than through a namespace object.
  • Why do people still repeat the rule that namespace imports break tree shaking?
    Because it used to be reliably true before bundlers learned to narrow fixed property accesses, and because it stays true in the cases that hurt most: CommonJS packages, and any file where the namespace object gets passed around. It is a rule of thumb that is wrong in detail but points at the right default.

saying these in an interview costs you the question

  • Says namespace imports always defeat tree shaking
  • Believes named imports guarantee elimination from any package
  • Ignores that a passed-around namespace object retains everything
  • Thinks import syntax can narrow a CommonJS module

context