Skip to content

Implement optimal JavaScript module design #4442

Open
@jaydenseric

Description

@jaydenseric

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 of node_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 of Rule + name:

    graphql-js/src/index.ts

    Lines 344 to 383 in ba4b411

    // Individual validation rules.
    ExecutableDefinitionsRule,
    FieldsOnCorrectTypeRule,
    FragmentsOnCompositeTypesRule,
    KnownArgumentNamesRule,
    KnownDirectivesRule,
    KnownFragmentNamesRule,
    KnownTypeNamesRule,
    LoneAnonymousOperationRule,
    NoFragmentCyclesRule,
    NoUndefinedVariablesRule,
    NoUnusedFragmentsRule,
    NoUnusedVariablesRule,
    OverlappingFieldsCanBeMergedRule,
    PossibleFragmentSpreadsRule,
    ProvidedRequiredArgumentsRule,
    ScalarLeafsRule,
    SingleFieldSubscriptionsRule,
    UniqueArgumentNamesRule,
    UniqueDirectivesPerLocationRule,
    UniqueFragmentNamesRule,
    UniqueInputFieldNamesRule,
    UniqueOperationNamesRule,
    UniqueVariableNamesRule,
    ValuesOfCorrectTypeRule,
    VariablesAreInputTypesRule,
    VariablesInAllowedPositionRule,
    MaxIntrospectionDepthRule,
    // SDL-specific validation rules
    LoneSchemaDefinitionRule,
    UniqueOperationTypesRule,
    UniqueTypeNamesRule,
    UniqueEnumValueNamesRule,
    UniqueFieldDefinitionNamesRule,
    UniqueArgumentDefinitionNamesRule,
    UniqueDirectiveNamesRule,
    PossibleTypeExtensionsRule,
    // Custom validation rules
    NoDeprecatedCustomRule,
    NoSchemaIntrospectionCustomRule,

    The same goes for AST nodes:

    graphql-js/src/index.ts

    Lines 254 to 312 in ba4b411

    // Each kind of AST node
    NameNode,
    DocumentNode,
    DefinitionNode,
    ExecutableDefinitionNode,
    OperationDefinitionNode,
    VariableDefinitionNode,
    VariableNode,
    SelectionSetNode,
    SelectionNode,
    FieldNode,
    ArgumentNode,
    ConstArgumentNode,
    FragmentSpreadNode,
    InlineFragmentNode,
    FragmentDefinitionNode,
    ValueNode,
    ConstValueNode,
    IntValueNode,
    FloatValueNode,
    StringValueNode,
    BooleanValueNode,
    NullValueNode,
    EnumValueNode,
    ListValueNode,
    ConstListValueNode,
    ObjectValueNode,
    ConstObjectValueNode,
    ObjectFieldNode,
    ConstObjectFieldNode,
    DirectiveNode,
    ConstDirectiveNode,
    TypeNode,
    NamedTypeNode,
    ListTypeNode,
    NonNullTypeNode,
    TypeSystemDefinitionNode,
    SchemaDefinitionNode,
    OperationTypeDefinitionNode,
    TypeDefinitionNode,
    ScalarTypeDefinitionNode,
    ObjectTypeDefinitionNode,
    FieldDefinitionNode,
    InputValueDefinitionNode,
    InterfaceTypeDefinitionNode,
    UnionTypeDefinitionNode,
    EnumTypeDefinitionNode,
    EnumValueDefinitionNode,
    InputObjectTypeDefinitionNode,
    DirectiveDefinitionNode,
    TypeSystemExtensionNode,
    SchemaExtensionNode,
    TypeExtensionNode,
    ScalarTypeExtensionNode,
    ObjectTypeExtensionNode,
    InterfaceTypeExtensionNode,
    UnionTypeExtensionNode,
    EnumTypeExtensionNode,
    InputObjectTypeExtensionNode,

    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 as validators, etc. that would build to Git-ignored paths such as graphql/validators/. If we don't do this, we would need a directory named something like dist to contain and Git-ignore all the build artefacts to avoid them being dumped in the project root. To avoid having to have graphql/dist/ in the import paths, it would the be necessary to use the package field exports to re-map imports from the root to the /dist/ directory. Re-mapping imports via the package field exports is an anti-pattern, because some CDNs serving the graphql package for HTTP imports just statically serve the package files and don't have clever routing that reads the export rules from the package.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:

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 a private 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:

https://github.com/jaydenseric/graphql-upload/blob/421707f3b4e2b0c18ed9beec8eeaf3a0c942841d/package.json#L42-L49

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:

https://github.com/jaydenseric/graphql-upload/blob/421707f3b4e2b0c18ed9beec8eeaf3a0c942841d/GraphQLUpload.mjs#L3

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

Image

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 and module.
    • 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.
  • Merge the PR as a semver major change for a new graphql release.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions