1 of 31

Deferred re-exports

export defer … from

Nicolò Ribaudo (Igalia, in partnership with Bloomberg)

April 2025

2 of 31

History

2023-11 Originally presented as part of the import defer proposal

2024-04 Presented more detail about the semantics, and the committee� decided to keep the concept aligned with import defer(using the same keyword).

2024-06 import defer advanced to Stage 2.7. export defer needed� more work, and got left behind from the main proposal (as� decided in the 2024-04 meeting).

2

3 of 31

Definition: "Barrel files"

3

4 of 31

Barrel files — Current ecosystem practices

The best DX for utility/components libraries is to re-export all their functions through a single entry point:

import { add, debounce, map, ... } from "lodash-es";

and not to ask users to import from their internal files:

import add from "lodash-es/add";

import debounce from "lodash-es/debounce";

import map from "lodash-es/map";

...

4

5 of 31

Barrel files — Current ecosystem practices

How do they support it?

export { default as add } from "./add.js";

export { default as divide } from "./divide.js";

export { default as debounce } from "./debounce.js";

export { default as map } from "./map.js";

export { default as each } from "./each.js";

export { default as some } from "./some.js";

export { default as isObject } from "./isObject.js";

...

5

6 of 31

Barrel files — The problem

I'm importing tens of modules just for a few that I'm actually going to use.

  • Unnecessary code loading
  • Unnecessary code execution

The DX benefits often make people ignore these problems.

6

7 of 31

Current userland solutions

7

8 of 31

Current userland solution: library-specific transforms

This was more popular in the past, but some libraries provide Babel plugin to, at build-time, replace "grouped" imports to "individual" imports.

8

import { map } from 'lodash'

import { add } from 'lodash/fp'

const addOne = add(1)

map([1, 2, 3], addOne)

import map from 'lodash/map'

import add from 'lodash/fp/add'

const addOne = add(1)

map([1, 2, 3], addOne)

9 of 31

Current userland solution (frontend): Tree Shaking

Bundlers are able to, in some cases, avoid bundling unused exports from a bundle:

  • through out-of-band annotations marking files as "safe to drop" (e.g. Webpack's "sideEffects": false in package.json)
  • through static analysis to understand which code can be dropped without observable changes (e.g. Rollup, Parcel)

Due to JS's dynamism, static analysis either detects too much or too little.

9

10 of 31

Current userland solution (Node.js): getter-exports

When using CommonJS, getter-based module.exports can help pruning unused branches in the module graph:

module.exports = {

get add() { return require("./add").default },

get debounce() { return require("./debounce").default },

// ...

};

const l = require("lodash");

l.add(1, 2); // only lodash/add is imported

10

11 of 31

The proposal

11

12 of 31

Web application example

12

// components-library

export {

Button, PrimaryButton

} from "./Button.js"

export {

Tooltip

} from "./Tooltip.js";

export {

From, Input, Checkbox

} from "./forms/index.js";

// handlers.js

import {

Button

} from "components-library";

export function showSubmit() {

$myApp.appendChild(

Button({ label: "Submit" })

);

}

All this code will slow down the app startup without being necessary

13 of 31

Web application example

13

// components-library

export {

Button, PrimaryButton

} from "./Button.js"

export {

Tooltip

} from "./Tooltip.js";

export {

From, Input, Checkbox

} from "./forms/index.js";

// handlers.js

import defer * as c from "components-library";

export function showSubmit() {

$myApp.appendChild(

c.Button({ label: "Submit" })

);

}

We use import defer, and components-library is only executed when actually needed

There is still a bunch of unnecessary work going on: ./Tooltip.js and ./forms/index.js are not needed at all.

14 of 31

Web application example - simpler

14

// components-library

export {

Button, PrimaryButton

} from "./Button.js"

export {

Tooltip

} from "./Tooltip.js";

export {

From, Input, Checkbox

} from "./forms/index.js";

// app.js

import {

Button

} from "components-library";

$myApp.appendChild(

Button({ label: "Submit" })

);

Ignore the "when" is some code needed, focus on the "if"

./Tooltip.js and ./forms/index.js are not needed at all, can we mark them as not needing execution?

15 of 31

Web application example - simpler

15

// components-library

export defer {

Button, PrimaryButton

} from "./Button.js"

export defer {

Tooltip

} from "./Tooltip.js";

export defer {

From, Input, Checkbox

} from "./forms/index.js";

// app.js

import {

Button

} from "components-library";

$myApp.appendChild(

Button({ label: "Submit" })

);

"only evaluate this export if one of the importer modules needs it"

16 of 31

Web application example - simpler - CommonJS solution

16

// components-library

module.exports = {

get Button() { return require("./Button.js").Button; },

get PrimaryButton() {

return require("./Button.js").PrimaryButton; },

get Tooltip() { return require("./Tooltip.js").Tooltip; },

get From() { return require("./forms/index.js").From; },

get Input() { return require("./forms/index.js").Input; },

get Checkbox() {

return require("./forms/index.js").Checkbox; },

};

// app.js

const {

Button

} = require("components-library");

$myApp.appendChild(

Button({ label: "Submit" })

);

17 of 31

export defer … from

Differently from import defer, re-exports' boundary can also be about if a module is evaluated rather than when

  • no need to have the module synchonously available
  • no need to eagerly look for top-level await

We can avoid loading the unused module altogether.

17

18 of 31

Web application example - simpler

18

// components-library

export defer {

Button, PrimaryButton

} from "./Button.js"

export defer {

Tooltip

} from "./Tooltip.js";

export defer {

From, Input, Checkbox

} from "./forms/index.js";

// app.js

import {

Button

} from "components-library";

$myApp.appendChild(

Button({ label: "Submit" })

);

This would only load&execute app.js, components-library, and components-library/Button.js

// components-library

export defer {

Button, PrimaryButton

} from "./Button.js"

export defer {

Tooltip

} from "./Tooltip.js";

export defer {

From, Input, Checkbox

} from "./forms/index.js";

19 of 31

export defer … from

Same goal as import defer (improve startup performance by reducing unnecessary initialization work), but with different loading/TLA semantics:

  • There is no need to pre-load/execute code related to a binding that is guaranteed to not be used.

19

20 of 31

export defer * from

export defer * from "hello" is not valid: you must always explicitly define the names imported from a module, because they need to be known to decide wether to load it or not.

20

21 of 31

A language-level solution

21

22 of 31

A language-level solution

  • Provides a guaranteed "tree-shaking" baseline, that does not rely on heuristics to detect side effects
    • (tools can still optimize more if they can prove it's not observable!)

  • Works when using ESM natively

  • Still useful when combined with import *, allowing granular control on evaluation

22

23 of 31

Confirm Stage 2?

Or advance from Stage 0 to Stage 1?

23

New proposal repo: https://github.com/nicolo-ribaudo/proposal-deferred-reexports * previously living in a branch/PR of the import defer repo

Spex text: https://nicolo-ribaudo.github.io/proposal-deferred-reexports/

* there are a couple TODOs left, but it's mostly complete

24 of 31

Integration semantics with import defer

24

25 of 31

import defer * & export

25

// app.js

import defer * as a from "./mod.js";

later(() => use(a.foo));

// mod.js

export { foo } from "dep-foo";

export { bar } from "dep-bar";

Load

Execute

app.js

Yes

Startup

mod.js

Yes

Later

dep-foo

Yes

Later

async deps of dep-foo

Startup

dep-bar

Yes

Never

async deps of dep-bar

Startup

26 of 31

import {...} & export defer

26

// app.js

import { foo } from "./mod.js";

later(() => use(foo));

// mod.js

export defer { foo } from "dep-foo";

export defer { bar } from "dep-bar";

Load

Execute

app.js

Yes

Startup

mod.js

Yes

Startup

dep-foo

Yes

Startup

async deps of dep-foo

dep-bar

No

async deps of dep-bar

27 of 31

import * & export defer

27

// app.js

import * as a from "./mod.js";

later(() => use(a.foo));

// mod.js

export defer { foo } from "dep-foo";

export defer { bar } from "dep-bar";

Load

Execute

app.js

Yes

Startup

mod.js

Yes

Startup

dep-foo

Yes

Later

async deps of dep-foo

Startup

dep-bar

Yes

Never

async deps of dep-bar

Startup

28 of 31

import defer * & export defer

28

// app.js

import defer * as a from "./mod.js";

later(() => use(a.foo));

// mod.js

export defer { foo } from "dep-foo";

export defer { bar } from "dep-bar";

Load

Execute

app.js

Yes

Startup

mod.js

Yes

Later

dep-foo

Yes

Later

async deps of dep-foo

Startup

dep-bar

Yes

Never

async deps of dep-bar

Startup

29 of 31

export defer *

29

30 of 31

export defer * from

export defer * from "hello" is not valid: you must always explicitly define the names imported from a module, because they need to be known to decide wether to load it or not.

30

31 of 31

export defer * as ns from

export defer * as ns from "hello" is currently not valid: it's ambiguous, and could mean either of the following:

… or a combination of the two.� This is not set in stone.

31

import defer * as ns from "hello";

export { ns };

export defer { ns } from "intermediate";

// mod.js

export * as ns from "hello";