Skip to main content
Zubin KhavarianZubin Khavarian

Prefer Named Exports

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

Editorial illustration of a walnut cabinet of twelve small cream drawers, each tagged with its own colored shape, beside one big kraft crate with no tag at all, every edge drawn in wobbly ink.

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 difference between named exports and default exports, 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.

Get new posts by email

One short email when a post goes out, about once a week. Unsubscribe any time.

Or follow along:RSSGitHubX

Zubin Khavarian, Software EngineerWritten by
READ~/blog/prefer-named-exports~4 min0%
zubin@zubin.dev: man keys

// go to

  • ghhome
  • gwwriting
  • gaapps
  • gpprojects
  • goopen source
  • gnnow
  • guuses

// move

  • jscroll down
  • kscroll up
  • ggtop of page
  • Gbottom of page
  • }next section (posts)
  • {previous section (posts)
  • ]newer post
  • [older post

// do

  • ⌘Ksearch everything
  • /search, too
  • ttoggle light / dark
  • ?this sheet
  • escclose