Skip to content

🚀 Feature: Enforce order of imports and exports #928

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
3 tasks done
GeorgeTaveras1231 opened this issue Mar 3, 2025 · 6 comments
Open
3 tasks done

🚀 Feature: Enforce order of imports and exports #928

GeorgeTaveras1231 opened this issue Mar 3, 2025 · 6 comments
Labels
status: in discussion Not yet ready for implementation or a pull request type: feature New enhancement or request 🚀

Comments

@GeorgeTaveras1231
Copy link

Bug Report Checklist

  • I have tried restarting my IDE and the issue persists.
  • I have pulled the latest main branch of the repository.
  • I have searched for related issues and found none that matched my issue.

Overview

This is a feature request and proposal for handling the exports and imports keys

Additional Info

Context

At the moment, the sort-collections rule improperly enforces alphabetical order on top-level condition objects in the exports and imports (if specified) keys alphabetically. Sorting condition objects alphabetically causes issue because resolvers that support import/export conditions use the first matching condition. This is especially bad when running the Eslint auto-fixer, which can introduce production issues for the consumers of the package.

In addition, enforcing a consistent order of the condition objects has a high-impact potential for teams maintaining large amounts of package.json files, supporting multiple code variants (syntaxes, environments, runtimes, etc). The more conditions you support, the easier it is to accidentally create a dead-code path (a condition that will never be met) and to accidentally break a consumer that depends on a particular set of conditional exports.

Proposal

eslint-plugin-package-json should enforce a consistent order of the exports and imports keys,
It should implement an order of the conditions object based on openly available knowledge of "known" conditions while allowing developers to specify custom conditions. In addition, it should sort the subpath objects to make them more readable.

Before explaining the proposal, I'll leave a simple description of the terms I will use in the rest of the proposal.

Terms

subpath and condition objects

Subpath exports objects are the objects that describe the package entry-points, where the keys start with ..

Subpath import objects are the objects that describe the private path aliases, where the keys start with #.

Condition objects describe the conditions supported by the entrypoint, where the keys do not start with . or #.

{
  "imports": {
    "#private-alias": {
      "import": "./path/to/private.mjs",
      "require": "./path/to/priate.cjs"
    } // <<--- condition object
  }, // <<--- subpath import object
  "exports" {
    "./sub": {
      "import": "./sub.mjs",
      "require": "./sub.cjs"
    } // <<--- condition object
  } // <<--- subpath export object
}

dead-code paths

Conditions in the condition object that will never be met.

Subpath Object Order

The Subpath Object is the easier part of this feature, so I will detail it first.

Goals

The goal is to make these objects more readable. I want to enforce an order that reads naturally and resembles a logical order when using wildcards (*).

Solution

  1. Sort alphabetically
  2. Increase comparison weight of wildcards (*) to move them after concrete subpaths.

Examples

Invalid

Note: Would be the same for import - except with # instead of ./

{
  "./b/*": "./b/*.js",
  "./b/sub": "./b/sub-override.js",
  "./b": "./b.js",
  "./a": "./a.js",
  "./c": "./c.js"
}
Valid

Note: Would be the same for import - except with # instead of ./

{
  "./a": "./a.js",
  "./b": "./b.js",
  "./b/sub": "./b/sub-override.js",
  "./b/*": "./b/*.js",
  "./c": "./c.js"
}

Condition Object Order

The condition object should be organized to avoid common problems.

Goals

The primary goal of the condition object order is to minimize dead-code paths. Dead-code paths are created when more than one condition is met by the resolver, but a more specific condition is nullified by a less specific condition.

The simplest example of this is when using the default condition before any other condition. The default condition is the ultimate fallback condition, so every resolver will match it regardless of any other matching condition

Example:

{
  "default": "./main.default.js",
  "import": "./main.mjs" // <<--- ❌ Dead-code path - this condition will never be reached because `default` always matches
}

A more nuanced example is the following:

{
  "import": "./main.mjs",
  "browser": "./main.browser.js" // <<-- ❌ Possibly dead-code
}

In the last example, the browser condition may be a dead-code path. The browser condition is generally used by bundlers (like Webpack, Rollup, etc), but they generally also use the import condition. So even if the bundle is targeting browsers, the browser condition will not be used.

Some condition pairs have a strict order requirement. For example, module must come before import, import must come before module-sync, module-sync must come before require - the relative order of these conditions should be enforced by our solution.

In some cases, there isn't a strict order requirement between known conditions, however, the other goal of the proposed condition order is to group similar conditions together.

For example, the rollup condition used by the Rollup bundler and the webpack condition used by Webpack don't have any overlapping scenarios, so their relative order does not matter; However, it would be nice to have these two next to each other as they are both conditions for bundlers.

Constraints

In my research, I've found 40 well-known conditions that are used by various open source tools and services, so the biggest challenge I've found is creating a widely applicable model to apply to each condition when determining their relative order.

I've concluded that it is impossible (or very difficult) to create a unified model for all of the conditions, as there are many configurations that produce valid packages without dead-code paths.

Take for example the require and browser conditions. require is used by resolvers looking for a CJS module and browser is used by resolvers in bundlers when targeting browsers.

Both of the following configurations are valid:

{
  "./browser-first": {
    "browser": "./browser-first.browser.js",
    "require": "./browser-first.cjs"
  },
  "./require-first": {
    "require": "./require-first.cjs",
    "browser": "./require-first.browser.js",
  }
}

In the first path (./browser-first) a bundler may use the browser condition if targeting a browser, but will use the require condition when targeting Node.js. The node CLI will also use the require condition.

In the second path (./require-first) a bundler that does not support require will use the browser condition if it is configured to target a browser. However, Node.js or a bundler configured to support commonjs will use the require condition.

This is to say, there isn't a way to globally enforce a relative order between these conditions. But it is still too easy to accidentally create dead-code paths.

Solution

My proposed solution to reduce the risk of dead-code paths while enforcing a correct order is multi-faceted.

1. Enforce categorized condition objects

To avoid accidental dead-code paths, we can add a rule that limits the use overlapping conditions in the same object - forcing developers to choose mutually exclusive conditions. The mutually exclusive conditions can be defined as condition groups, which can be extended if necessary.

Example:

Configuration example

/// rule options
{
  "conditionGroups": {
    "targetRuntime": [
      "browser",
      "node",
    ],
    "referenceSyntax": [
      "module",
      "require",
      "import"
    ]
  }
}

This configuration communicates that you can only use browser and node in the same object, and module, require and import in the same object, but you cannot mix-match between groups. In addition, the order of the condition name arrays should be the enforced key order within the condition object. So module must come before require, etc.

/// ❌ Invalid configuration - category mix-match
{
  "browser": "./main.browser.js",
  "module": "./main.module.js",
  "import": "./main.mjs",
  "require": "./main.cjs"
}
/// ❌ Invalid configuration - invalid order
{
  "browser": "./main.browser.js",
  "default": {
    "require": "./main.cjs", /// ❌ module should come before "require"
    "module": "./main.module.js",
    "import": "./main.mjs"
  }
}
/// ✅ Valid configuration - no mix-match and proper order
{
  "browser": "./main.browser.js",
  "default": {
    "module": "./main.module.js",
    "import": "./main.mjs",
    "require": "./main.cjs"
  }
}

2. Fallback conditions and sorting exceptions

The treatment of condition categorization should not apply to a list of known conditions that can be considered fallback conditions.

default condition

The default condition can be added to any condition object and must always be at the end.

types and types@{typesVersionsSelector}

The types and types@{typesVersionsSelector} conditions can also be added to any condition object and should generally be at the top (though there is some flexibility with this because of how typescript works).

The types@{typesVersionsSelector} condition should come before the types condition and should be sorted using a version range sorting algorithm.

/// ❌ Invalid configuration - invalid order
{
  "types": "./types.d.ts",
  "types@>4.1": "./types.4.1.d.ts"
}
/// ❌ Invalid configuration - invalid order
{
  "types@>4.1": "./types.4.1.d.ts",
  "types@>4.2": "./types.4.2.d.ts", // ❌ dead-code path 4.2 is matched by 4.1 above
  "types": "./types.d.ts"
}
/// ✅ Valid configuration - proper order
{
  "types@>4.2": "./types.4.2.d.ts", 
  "types@>4.1": "./types.4.1.d.ts",
  "types": "./types.d.ts"
}

Handling this scenario will require a non-trivial solution to parse the version ranges and sort them based on specificity.

3. Provide default groups

Most conditions used in the wild are well known conditions with well established semantics and relationships with other conditions. This can be captured in the rule's default configurations. These are the groups and conditions I've identified in my research:

Note: the intention is to enforce the order they are listed in their corresponding lists. The order is purely aesthetic in some cases, in other cases, it is to avoid dead-code paths, if there is some overlap between the conditions.

Environment

keys:

  • test
  • development
  • production

Note: Order is aesthetic

Bundler

keys:

  • vite
  • rollup
  • webpack

Note: Order is MOSTLY aesthetic. I suspect that vite must come before rollup as Vite uses Rollup under the hood.

Edge-Runtime

keys:

Note: Order is alphabetic

Edge-runtimes can overlap with many of the target runtimes (Example: a node edge, a deno edge). Therefore, they must be isolated in their own group.

Server Variant

keys:

  • react-server

It may not be obvious why react-server is in its own group. This is because it can overlap with many of the "Target Runtime", "Reference Syntax", and "Edge Runtime" conditions.

Target Runtime

keys:

  • macro
  • bun
  • deno
  • browser
  • electron
  • kiesel
  • node-addons
  • node
  • moddable
  • react-native
  • worker
  • worklet

Note: Order is aesthetic with few exceptions.

  • macro must come before bun, as this is a special Bun condition.
  • deno and bun must come before browser, since both Deno and Bun can be used to create a browser bundle.
Reference Syntax

keys:

  • svelte
  • asset
  • sass
  • stylus
  • style
  • script
  • module
  • import
  • module-sync
  • require

Note: Order is strict for a couple reasons

  • Svelte can resolve resources in multiple syntaxes (sass, style, module)
  • Sass can resolve style and sass conditions
  • style is used to resolve CSS Modules. CSS Modules are JS modules under the hood. Should come before other JS syntaxes to avoid resolving a JS module.
  • script condition may resolve to JS that can be downloaded by a <script /> tag. Should come before other JS formats
  • module must come before import. import is node's standard for async ES Modules (ESM files that may have top-level await). module is used by bundlers primarily and the semantics are more flexible than Node's for import modules
  • import must come before module-sync. import supports both sync and async ES Modules, module-sync can be used to require(ESM)
  • module-sync must come before require. The primary purpose of module-sync is to move away from the CJS format, package developers can export sync modules that can be required.

4. Support for unknown conditions

It's not possible to predict all the possible conditions that will exist in the future or conditions that are project specific. In addition, we can't predict the relationship between those conditions and well established/known conditions.

We can approach this by restricting the use of unknown conditions by default, but giving developers the choice to configure the rule, to add their own conditionGroups or extend the default conditions in the predefined groups.

Example configuration:

{
  "conditionGroups": {
    "...": true, /// Extend default groups,

    "referenceSyntax": [
      "...", /// <<-- extend default list
      "syntax-from-the-future" /// <<-- Suffix default list
    ],
    "serverVariant": [
      "react-server@3" /// <<-- Prefix default list
      "...", /// <<-- extend default list
    ],
    "targetRuntime": [ /// <<-- Override default list, removes ability to use other conditions in default
      "browser",
      "node",
      "new-js-runtime"
    ]
  }
}

Additional requirements

  1. Recursive: The rule check must be applied recursively, as condition objects can be nested.

Drawbacks

These are the drawbacks I've identified. I may edit this list as we discover more drawbacks.

More verbose export objects.

Developers will be forced to create more verbose objects. This can be tedious in small/simple projects. In larger projects, this is offset by the benefit of clarify.

Example

{
  "import": "./main.mjs",
  "browser": "./main.browser.js"
}

Will have to be written as

{
  "import": "./main.mjs",
  "default" :{
    "browser": "./main.browser.js"
  }
}

or

{
  "browser": {
    "import": "./main.browser.mjs",
    "default": "./main.browser.js"
  },
  "default" : "./main.mjs"
}

Resources

I've used the following resources to come up with this proposal

@michaelfaith
Copy link
Collaborator

Thanks for submitting this and thanks especially for all of the detail. I am a bit concerned about the amount of complexity that's involved (which could be completely merited). I'm a big fan of starting small and iterating. So, I'm wondering if we should maybe start with a smaller subset of this proposal; release that, see how it works, get feedback, and then continue with the rest if it still makes sense to. If we did go that route, then what would be that MVP? I think a good MVP could be

  • the alphabetical sorting of the relative path keys (including the wildcard handling)
  • enforcing default should be last in a condition object
  • enforcing types (and it's @ variations) should go first

That leaves the different categorization and rules of groupings out for the MVP, and something that could be built on as a follow-up iteration after seeing how it works in the wild.

@JoshuaKGoldberg thoughts?

@JoshuaKGoldberg
Copy link
Owner

In the friendliest of tones: tl;dr. 🙂

I don't have the bandwidth to pour over and deeply understand the proposal of this complexity and nuance. Agreed that it is not warranted for the problem of this scale or at this point.

Requesting a proposal that fits in <500 words.

@GeorgeTaveras1231
Copy link
Author

@michaelfaith Thats a good instinct and I would approach it similarly. The only modification I would make is to ignore the types@{version} variant, and divert attention to the relationship between some of the common conditions (like module, require, import, etc). I may be biased but I think this would have a bigger impact and the version range parsing for the types@{version} variant is a lot of complexity for what feels like an edge case / uncommon pattern.

@JoshuaKGoldberg sorry 🫣 lol

I will sometimes over-invest in the research and ideation phase - and majority of the text is justifying the ideas, to show I'm not just making arbitrary decisions lol but the intention is not to suggest that all is done in one go - but to come up with a complete (or close to complete) vision that we can break up into parts that make sense to introduce in isolation.

Is it okay if we use this issue to track all of the features but use small issues for each individual part?

Since everything relates to the same issue of sorting the imports and exports, I'm hesitant to close this issue. And as I work on the individual parts, I may want to use this issue as reference or as record of the overlapping decisions.

@michaelfaith
Copy link
Collaborator

The only modification I would make is to ignore the types@{version} variant, and divert attention to the relationship between some of the common conditions (like module, require, import, etc).

Dropping types@{version} handling for the MVP sounds good to me. What do you mean by "divert attention".

@GeorgeTaveras1231
Copy link
Author

@michaelfaith sorry that was a weird way to say that 😄 . I mean, instead of working on the types@{version}, use our MVP bandwidth on enforcing the order of those 4 conditions.

@michaelfaith
Copy link
Collaborator

Gotcha. The main reason I left those out in my recommendation was because that starts to delve into the categorizations (i.e. what belongs with what). But if that can be introduced in a simple way without the additional overhead of the categories, and is extensible so that more can be added to it, then I could get behind that.

@michaelfaith michaelfaith added the status: in discussion Not yet ready for implementation or a pull request label Apr 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: in discussion Not yet ready for implementation or a pull request type: feature New enhancement or request 🚀
Projects
None yet
Development

No branches or pull requests

3 participants