Skip to main content
Zubin KhavarianZubin Khavarian

Prefer Named Exports

· Last updated · 4 min read · #javascript #typescript

Editorial illustration of a wooden apothecary cabinet with many small tagged drawers, beside one large unlabeled crate.

Prefer named exports.

Default exports feel lighter when a file has one main thing, and I used them for years. Then I renamed a component. The file name updated, the paths updated, and half the imports still called it by its old name. TypeScript didn’t mind at all, because a default import can be called whatever you like.

That’s the whole problem, and the rest of this post is the detail.

What is a named export?

A named export is a binding with a name, and you import it by that name, in braces.

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}
import { add, subtract } from "./math.js";

You can still alias it, but the alias is written out where everyone can see it.

import { add as plus } from "./math.js";

A default export has no name at the import site. Each file that imports it makes one up.

export default function add(a, b) {
  return a + b;
}
import add from "./math.js";
import sum from "./math.js";
import MathFn from "./math.js";

All three compile, and none of them has to match the function you actually wrote.

Why does that matter?

Say you default-export a component called ProductDrawer. A year later it’s a dialog, so you rename the function and the file to ProductDialog. The language service updates the import paths for you.

It doesn’t update this:

import ProductDrawer from "./ProductDialog.js";

There’s no error. Search the codebase for ProductDialog and you’ll miss every call site that still says ProductDrawer.

default exportProductDialog.tsxexport default functiona.tsxProductDrawerb.tsxDrawerc.tsxProductDialogsearch “ProductDialog”: finds 1 of 3named exportProductDialog.tsxexport functiona.tsxProductDialogb.tsxProductDialogc.tsxProductDialogrename: all 3 follow, or the build fails
Rename the component and see who notices. Default imports keep whatever name each file invented.

With a named export, the build fails until every import moves, or until someone writes import { ProductDialog as ProductDrawer } on purpose. Either way the mismatch is visible instead of silent.

It’s also why autocomplete works better. Type import { | } from a module and your editor lists its exports. A default import autocompletes the path and then lets you type any identifier you want.

Renames follow the same logic. Rename a named export in TypeScript and the language service can update every import. That’s the language service doing it, by the way, not ESLint; I’ve seen people credit the linter for this more than once.

Does this help tree-shaking?

Sometimes, but not in the way older explainers claimed.

Tree-shaking needs static import and export. webpack’s own guide (opens in a new tab) shows the case that actually breaks: a default-exported object full of helpers can’t be shaken apart.

export default {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};
import math from "./math.js";
math.add(1, 2);

The bundler can see you using math, but it can’t prove nobody touches subtract, so both functions stay. Named exports give it one binding per function:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
import { add } from "./math.js";

Now subtract can go.

default-exported objectnamed exportsexport default {…}addsubtractbundleaddsubtractunused, still shippedmath.add(1, 2)export addexport subtractbundleaddimport { add }subtract dropped
Only math.add is used on either side. The object keeps subtract in the bundle; separate named exports let it go.

A default-exported function is fine. If nothing imports the module, the whole module drops. So the myth is “named exports tree-shake and default exports don’t,” and the truth is “don’t default-export a namespace object.”

And remember that ESM itself doesn’t tree-shake anything at runtime. The bundler does.

When is a default export the right call?

When the host requires one.

Next.js pages and layouts (opens in a new tab) default-export the component for each route. File-based frameworks do this so there’s one obvious thing to render, and if you fight it you get The default export is not a React Component.

So do it there, and name the function anyway:

export default function Page() {
  return <h1>Home</h1>;
}

The name still shows up in stack traces and in React’s component tree. The default is for the framework, and everything else in the file can be named.

Even a module that really is one thing still wants a named export if people import it. export function cn() beats an export default function that every caller names differently.

What should you not do?

Default-export an object of helpers. It’s a CommonJS habit. It defeats tree-shaking and it’s a worse version of import *.

Re-export defaults through a barrel without naming them. export { default as Button } from "./Button.js" is the least-bad form. export { default } from "./Button.js" just spreads the naming problem further.

Ban default exports with a lint rule, then spend a day fighting page.tsx. Turn the rule off for the files the framework owns.

I’d start with named exports everywhere in application code, and default-export only where a framework leaves you no choice.

Stay in touch

Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.

Follow @zkmake
Zubin Khavarian, Software EngineerWritten by