Description
As discussed in the June 2025 GraphQL.js Working Group meeting (see YouTube recording, and also graphql/graphql-js-wg#161), that along with publishing (ideally pure) ESM, optimizing the published GraphQL.js JavaScript module structure will make the GraphQL ecosystem more performant at build and runtime, and greatly improve performance when consuming the graphql
package modules via ESM HTTP imports in servers (such as Deno) and browsers by reducing loading waterfalls and avoiding downloading and parsing code redundant to the app.
A detailed explanation of optimal JavaScript module design and its benefits can be found in this article:
https://jaydenseric.com/blog/optimal-javascript-module-design
The core principal is that each public export of the package should have it's own deep-importable module, where it can be accessed without bundling/downloading/parsing any other code than is necessary to power the specific thing being imported. So, one default export per module file. A great convention is to name the file exactly after the name of the export, e.g. the scalar GraphQLInt
would be a default export in the module GraphQLInt.mjs
. Internal things should also have their own seperate modules and be directly imported via relative deep imports so that when a public export is imported, only the absolutely necessary internal dependencies get loaded in the module graph.
The antithesis of optimal module design is index/"barrel" modules that have a bunch of named exports. While we can offer deep imports for the entire API as well as sub-optimal index modules, in practice this is an anti-pattern because the community will not always understand that the correct way is to always do deep imports and never ever import from the main index, and they will publish a mix of deep and non deep imports in GraphQL ecosystem packages. Even if you know what you're doing and want to deep import, your editor may notice both ways are available and annoyingly bias towards auto-importing things from the main index. It takes just one dependency importing from the main index to pull every GraphQL.js module into your app and negate all the best practice deep imports everywhere else in your app and dependencies.
If users should never ever import from the index modules, instead of fighting a losing educational battle, just don't publish the index modules! The entire GraphQL JS ecosystem will fall effortlessly into the pit of success. GraphQL.js "glues" the ecosystem together, so it's important to get this right in the graphql
package.
Here is an example where importing from the main index module in a browser or Deno will undesirably cause the entire GraphQL.js library to be loaded, and with extra waterfall loading steps as first the index module is cached, and then it recursively caches nested imports in the module graph:
import { GraphQLInt } from "https://unpkg.com/[email protected]/index.js";
For a Node.js project, an import like this will cause the entire GraphQL.js library to be read from the filesystem and be parsed by the runtime:
import { GraphQLInt } from "graphql";
If you use a tree-shaking bundler to process the above import, it will have to work hard to attempt to figure out what library code your app doesn't actually use and eliminate it from the final bundle, increasing build times.
All the above issues can be avoided if it's possible to import like this instead:
import GraphQLInt from "graphql/GraphQLInt.mjs";
If we want, we can arbitrarily group related modules in directories (e.g. graphql/scalars/GraphQLInt.mjs
), but that has downsides:
- It introduces subjective decisions about how to group things under what names, making it more difficult to coordinate decisions and contributions.
- Extra import path segments (e.g.
/scalars/
) bloat the file size of modules that import a lot from GraphQL.js, increasing the size ofnode_modules
on disk, and the amount of bytes web apps using native ESM and HTTP imports have to download and cache at runtime. - Users manually typing deep import paths are likely to remember the name of the thing they want to import (e.g.
GraphQLInt
), but they might forget how it is arbitrarily nested under a path like/scalars/
. If everything is available from the package root (i.e.graphql/
+ the export name) then it's easy to remember, and there is discoverability as intellisense provides suggestions while you begin typing the path. - The extra import path segments add visual noise at the top of files. When lots of things are deep imported, it's nice to avoid the repetition in vertical columns of text.
- It adds a (small) friction to maintainers/contributors working on GraphQL.js modules as you need to do relative import paths that reach up out of the correct directory and back down into other directories. When moving code or your attention around different parts of the codebase, it's a breath of fresh air to be able to always just write
./
+ the name of the thing you want.
There are upsides though:
-
Imports of related things will sort together in projects that have linters/formatters sorting imports. This is not a big factor in my own open source packages because they usually have inventory style of naming of things so the names naturally sort together. The GraphQL.js public exports, unfortunately, sometimes names related things with a common suffix instead of a prefix. For example, validation rules are name +
Rule
, instead ofRule
+ name:Lines 344 to 383 in ba4b411
The same goes for AST nodes:
Lines 254 to 312 in ba4b411
It's not all bad though, because a lot of names begin with
GraphQL
,assert
,is
, etc. and will sort nicely. -
Because this package has a build-step producing the published artefacts, it would be fairly easy to have a
src
directory containing sub directories such asvalidators
, etc. that would build to Git-ignored paths such asgraphql/validators/
. If we don't do this, we would need a directory named something likedist
to contain and Git-ignore all the build artefacts to avoid them being dumped in the project root. To avoid having to havegraphql/dist/
in the import paths, it would the be necessary to use the package fieldexports
to re-map imports from the root to the/dist/
directory. Re-mapping imports via the package fieldexports
is an anti-pattern, because some CDNs serving thegraphql
package for HTTP imports just statically serve the package files and don't have clever routing that reads the export rules from thepackage.json
to re-route requests.
Normally my preference is to avoid sub-directories and publish everything from the root (which is easy when the project doesn't have a build step), but this aesthetic choice doesn't greatly affect the performance of the optimal module design other than increasing file size.
Actioning optimal module design is somewhat related to supporting native ESM:
- Suggestion: Bundling in v17, ESM, CJS, and the dual package hazard #4062
- ESM named exports are not available with "type": "module" #2721
There are many nuances about anti-patterns and best practices for publishing pure ESM optimal modules, relating to package exports
field, etc. I haven't captured yet in this particular issue but can be articulated over time where relevant.
There are a few ways to approach public and private modules regarding directory structure and the package field exports
. Personally, I like to simply manually list the public modules in the package exports
, and anything not listed is private and automatically blocked from being imported by runtimes and bundlers. This has upsides:
- It gives a complete list of public things that exist in the package for certain dev tools, so they don't need to read the files and attempt to apply export rules to figure out what's available.
- If we decide to change something from private to public, you just add an
exports
field entry for it and don't have to physically move around files (e.g. out of aprivate
directory) which can cause merge conflicts for other PRs touching these files.
But it also has the downside of increased risk of human error as you have to manually curate a list of what is public, and remember to expand it when adding new public modules in the future.
An alternative approach is to have package exports
rules that automatically make all modules in certain directories (e.g. validators/
) public, except when nested under a directory called private
.
Reference projects
graphql-upload
features pure ESM and optimal JavaScript module design, with no main index module and the only way to consume it exports is via deep imports, e.g:
import GraphQLUpload from "graphql-upload/GraphQLUpload.mjs";
You can see how the package exports
field is implemented:
The ESLint plugin eslint-plugin-optimal-modules
isn't configured because manually enforcing correctness is viable for such a stable project with a small module count.
Note that it would be great to be able to deep import from graphql
once that's possible here:
Other packages with a lot more public exports are graphql-react
, device-agnostic-ui
, and the buildless React web app framework Ruck, which are actually used together to serve the article without any build steps, with pure ESM via HTTP imports in Deno and the browser:
https://jaydenseric.com/blog/optimal-javascript-module-design

It would be really cool if the graphql
package had optimal module design, to allow building GraphiQL like front end experiences in web apps without any build steps or bundling.
Proposal
- Decide if modules will be organised in directories. If so, what are the directories called.
- Decide if we will also remove index modules from the package, to force the community to adopt the new deep imports API. I recommend removal!
- Audit the 70+ open PRs to decide which will be merged prior to locking any further merges until the optimal module design PR is merged. This will avoid terrible merge conflicts as many project files will be moved around.
- Create the optimal module design PR.
- Setup the ESLint plugin
eslint-plugin-optimal-modules
that bans named exports with an explanatory lint error message. - Setup ESLint rules to enforce that module file names match exactly the name of the thing being exported, and that default imports match the name of the thing being imported.
- Move all named exports within the project into seperate module files with a default export, named after the exported entity. VS Code TypeScript refactoring features can be used to automatically update all affected import paths as the exports are being moved.
- Delete index modules from the project.
- Remove the package fields
main
andmodule
. - Add the package field
exports
, defining the public API and automatically preventing imports of private modules. - Update all imports in documentation to the new deep imports.
- Setup the ESLint plugin
- Merge the PR as a semver major change for a new
graphql
release.