Description
Prologue
The following proposal aims to address the challenges of seamless cross-package development in Node.js, particularly for packages written in ESM/TypeScript. It builds on existing features like Node.js Package Entry Points and proposes enhancements to improve the developer experience, code reusability and safety.
Readers will find a recollection of the current state of the art for configuring most popular tools in the ecosystem and potential workarounds for some of the limitations while we transition to a more standardized and native solution.
Proposal might seem long, but we believe solutions could be simple and straightforward, and the benefits of implementing them would be significant for the Node.js community.
Context
This is the full story. If you want to get right to the problem, please refer to The Problem - TL;DR below.
Node.js Package Entry Points is an awesome feature. Yet, there are still some interesting use cases we've not been able to fully cover.
To make the point, we will go through our typical monorepo setup, after almost 10 years of experience on the matter at scale (but beware that this proposal is not limited to monorepos, it can be applied to any Node.js package):
-
So, it is organised into different levels of packages, optionally arranged in
n
number of workspaces:dev-package-example-1 dev-package-example-2 dev-package-example-n
core-package-example-1 core-package-example-2 core-package-example-n
app-package-example-1 app-package-example-2 app-package-example-n
-
All packages are written in ESM/TypeScript.
-
For
core
packages (a.k.a., libraries), we produceesm
,cjs
andtypes
outputs when building. Here we do some transpiling and adding non-polluting polyfills. -
For
app
packages we use bundlers (for anything, like: BE microservices, lambdas, FE web applications, FE hybrid mobile applications, prototyping boards, etc.). -
Yarn Workspaces or similar package manager features allow us to consume any package within other packages, but more relevantly, to consume any package outside of workspaces themselves (e.g., in configuration files).
Historically, one of the challenges of this kind of setup has been having to build and then re-build packages every time we make a change to their source code, so that other packages can consume the changes. Sometimes even ending in an egg and chicken situation.
Today, the way to go is to use conditional exports to make the entire monorepo work without having to build anything (we call this final goal: "seamless cross-package development").
Basically, each package that exports something, has at least one exports
configuration entry point similar to this:
{
"exports": {
".": {
"types": "./types/index.d.ts",
"development": "./src/index.ts",
"import": "./esm/index.js",
"require": "./cjs/index.js",
"default": "./cjs/index.js"
}
}
}
The development
part does the magic: It points directly to the source code, as opposed to the other conditions that point to paths that do not exist until packages are built.
Finally, it is a matter of configuring each individual tool and library to use development
as a custom user condition, as required.
We are very happy to see that most of the ecosystem has adapted or is adapting to exports
specifications.
For example, at the time of writing, there is already good out-of-the-box support for "seamless cross-package development" by:
- Webpack
- Metro
- ESLint
- TypeScript
- Jest
And many others! Also, mainstream IDEs and things like watchers and hot module replacement seem to work pretty well.
So far, so good!
The Problem - In Context
This is an introduction to the problem within the above context. If you prefer the abstracted version, please refer to The Problem - TL;DR below.
The challenge of seamless cross-package development becomes especially complex when dealing with dev
packages, particularly in projects aiming for a full ESM/TypeScript codebase.
These dev
packages typically include tools and utilities used during development but not in production, such as:
- Custom ESLint plugins.
- Custom Babel plugins.
- Custom utilities for managing the monorepo.
- Custom CLI tools.
- Bootstrap logic.
- Configuration files templates.
- Etc.
Most of this code is ultimately consumed by third-party tool configurations within the repository (e.g., ESLint, Jest, Babel, PM2, Cypress, etc.)
While getting these tools to work with ESM and TypeScript once they’re running is one challenge (see above), a more complex problem lies in their configuration and invocation. Tooling often struggles to import and execute ESM/TypeScript code directly in configuration files or bootstrap logic, especially when source code and those configurations span across multiple internal packages.
There’s currently no clear, standardized way to handle this. As of now, fully enabling this workflow without significant compromises remains difficult, if not impossible.
The Problem - TL;DR
How do we create a Node.js package that satisfies all the following requirements at the same time?
NOTE: For practical purposes, we will assume the latest Node.js version at the time of writing (v24.1.0
).
1. Source code is written in ESM/TypeScript
STATUS: SUPPORTED ✅
Well, there is not much to say here. Just beautiful ESM and TypeScript.
2. Exposes multiple variants through different entry points, some requiring a build step
STATUS: SUPPORTED ✅
{
"exports": {
"./declarations/globals": {
"types": "./types/globals.d.ts",
"development": "./src/globals.d.ts",
"default": "./types/globals.d.js"
},
"./utils": {
"types": "./types/utils/index.d.ts",
"development": "./src/utils/index.ts",
"import": "./esm/utils/index.js",
"require": "./cjs/utils/index.js",
"default": "./cjs/utils/index.js"
},
"./e2e/*": {
"types": "./types/e2e/*.cy.d.ts",
"development": "./src/e2e/*.cy.ts",
"import": "./esm/e2e/*.cy.js",
"require": "./cjs/e2e/*.cy.js",
"default": "./cjs/e2e/*.cy.js"
},
".": {
"types": "./types/index.d.ts",
"development": "./src/index.ts",
"import": "./esm/index.js",
"require": "./cjs/index.js",
"default": "./cjs/index.js"
}
}
}
cjs
, esm
and types
are built outputs directories, while src
is the source code directory.
3. Can be consumed by other packages without building it
STATUS: SUPPORTED ✅
This is mainly achieved by bundlers. They will take care of resolving and loading modules during development, with or without watch mode, hot module replacement support, etc. (e.g., Webpack's Dev Server), and when building for production.
Most bundlers offer a wide range of configuration options and pluggable features to support all the requirements in this proposal. In particular, they typically accept custom user conditions (so we can leverage the development
condition from the exports
configuration above) and extension aliases (so we can use .js
extensions in TypeScript files. See point 5
below).
There is also full native support if wanting to run a Node.js application directly by using --conditions
and existing TypeScript support (with some caveats. See point 5
below).
Webpack
Use a configuration similar to this:
module.exports = {
resolve: {
// https://webpack.js.org/configuration/resolve/#resolveextensionalias
extensionAlias: {
// ENABLE ".js" EXTENSION HANDLING IN TYPESCRIPT FILES
'.js': ['.js', '.jsx', '.ts', '.tsx']
},
// https://webpack.js.org/configuration/resolve/#resolveconditionnames
conditionNames: [
// ENABLE SEAMLESS CROSS-PACKAGE DEVELOPMENT
'development',
'browser',
'module',
'import',
'require',
'default'
]
// {...}
}
// {...}
};
Metro
Use a configuration similar to this:
const {resolve} = require('node:path');
const {getDefaultConfig} = require('expo/metro-config');
const config = getDefaultConfig(projectRoot);
// WATCH ALL FILES WITHIN THE MONOREPO
config.watchFolders = [resolve(process.env.MONOREPO_ROOT)];
// LET METRO KNOW WHERE TO RESOLVE PACKAGES AND IN WHAT ORDER
config.resolver.nodeModulesPaths = [
resolve(process.env.PROJECT_ROOT, 'node_modules'),
resolve(process.env.MONOREPO_ROOT, 'node_modules')
];
// https://metrobundler.dev/docs/configuration/#resolvermainfields
config.resolver.resolverMainFields = ['react-native', 'browser', 'main'];
// https://metrobundler.dev/docs/configuration/#unstable_enablepackageexports-experimental
config.resolver.unstable_enablePackageExports = true;
// https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental
config.resolver.unstable_conditionNames = [
// ENABLE SEAMLESS CROSS-PACKAGE DEVELOPMENT
'development',
'react-native',
'browser',
'require',
'default'
];
// https://metrobundler.dev/docs/configuration/#resolverequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName.startsWith('.')) {
// ENABLE ".js" EXTENSION HANDLING IN TYPESCRIPT FILES
return context.resolveRequest(
context,
moduleName.replace(/\.js$/, ''),
platform
);
} else {
return context.resolveRequest(context, moduleName, platform);
}
};
module.exports = config;
4. Can be understood by third-party tools without building it
STATUS: SUPPORTED ✅
Mainly achieved by instructing such tools to use the development
condition in the exports
configuration above as well as configuring extensions as required.
Here are some examples of how to configure some of the most popular tool in the ecosystem for seamless cross-package development:
ESLint
Use a configuration similar to this:
module.exports = {
parser: '@typescript-eslint/parser',
// https://typescript-eslint.io/getting-started/typed-linting/
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
// https://typescript-eslint.io/packages/parser/#projectservice
projectService: true,
// https://typescript-eslint.io/packages/parser/#tsconfigrootdir
tsconfigRootDir: __dirname
// {...}
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/extensions': ['.ts', '.tsx'],
'import/external-module-folders': [
'node_modules',
'node_modules/@types'
],
'import/resolver': {
node: {
// https://github.com/import-js/eslint-plugin-import
// ENABLE ".js" EXTENSION HANDLING IN TYPESCRIPT FILES
extensions: ['.ts', '.tsx']
},
typescript: {
// https://github.com/import-js/eslint-import-resolver-typescript
alwaysTryTypes: true,
mainFields: [],
exportsFields: ['exports'],
conditionNames: [
// ENABLE SEAMLESS CROSS-PACKAGE DEVELOPMENT
'development'
]
},
exports: {
// https://github.com/cyco130/eslint-import-resolver-exports
conditions: ['development'],
unsafe: true
}
}
// {...}
}
// {...}
};
TypeScript
Use a configuration similar to this:
{
"compilerOptions": {
"paths": {
"@scope/*": [
"./workspace-1/*",
"./workspace-2/*",
"./workspace-n/*"
]
},
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"customConditions": ["development"]
}
}
See:
Optionally use TypeScript's Project References.
Jest
Use a configuration similar to this:
module.exports = {
// https://jestjs.io/docs/configuration#testenvironment-string
testEnvironment: 'node', // CUSTOM TEST ENVIRONMENTS, OTHER THAN 'jsdom' AND 'node' MIGHT AFFECT `testEnvironmentOptions` OPTIONS BELOW
// https://jestjs.io/docs/configuration#testenvironmentoptions-object
testEnvironmentOptions: {
customExportConditions: [
// ENABLE SEAMLESS CROSS-PACKAGE DEVELOPMENT
'development',
'node',
'require',
'default'
]
},
// https://jestjs.io/docs/configuration#modulenamemapper-objectstring-string--arraystring
moduleNameMapper: {
'[.]module[.]css$': 'identity-obj-proxy',
// ENABLE ".js" EXTENSION HANDLING IN TYPESCRIPT FILES
'^([.]{1,2}/.*)[.]js$': '$1'
},
// https://jestjs.io/docs/configuration#modulefileextensions-arraystring
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node'
]
// {...}
};
5. Can be consumed by configuration files of third-party tools without building it
STATUS: PARTIALLY SUPPORTED
The good:
- When tools are manually executed (e.g., via CLI or programmatically), you can almost always set
--conditions development
(directly or viaNODE_OPTIONS="--conditions development"
) and source code will be hopefully loaded according to theexports
configuration above. - Independently of the official configuration types supported by each tool (e.g.,
.js
,.mjs
,.cjs
,.ts
,.mts
,.cts
), modern Node.js versions support requiring ESM modules from CommonJS files and also support TypeScript files natively. So, even if the configuration file itself is not written in ESM/TypeScript (third-party configuration lookup/resolution could be a limitation for this), it could still consume our package in question (as long as the above point applies). This seems to be working well for most tools. - You can use TypeScript's
erasableSyntaxOnly
option to make sure Node.js native TypeScript type-stripping is enough (default) or throw--experimental-transform-types
in the mix as well (though this would also require control over Node.js invocation).
The bad:
- When tools are executed by IDEs (e.g., live linting, test running) or other means, that might even spawn sub-processes, you may not be able to easily set
--conditions development
(orNODE_OPTIONS="--conditions development"
) or it may not be passed down to those sub-processes. - It is important to mention that using Node.js native TypeScript support requires you using fully specified import paths (as per ESM specification), but particularly using
.ts
extensions, which ended being a huge contradiction after TypeScript defended using.js
extensions for years. This can be a major blocker for big projects that have been using.js
extensions all this time:- https://stackoverflow.com/questions/75807785/why-do-i-need-to-include-js-extension-in-typescript-import-for-custom-module
- Provide a way to add the '.js' file extension to the end of module specifiers microsoft/TypeScript#16577
- Rewrite imports with ".ts" extension to ".js" to support Nodejs's "--experimental-strip-types" microsoft/TypeScript#59597
- https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths
- Almost every famous tools have been aiming for years to fully support ESM/TypeScript configuration files, but lack of a clear path to do so has been a major blocker. For the same reason, some authors have opted for custom workarounds that are unstable and not future-proof.
Essentially, when we don't have control over how Node.js is invoked (and this is a very common scenario during development), there is no way of setting up seamless cross-package development for packages written in ESM/TypeScript that serve configuration files written in ESM. No control for export conditions or extensions.
NOTE: Actually, for configuration files written in CJS (or ESM using dynamic imports or createRequire
) there are some unideal workarounds using loaders (more on this later). But for pure ESM, using static imports, there is absolutely no way if not controlling how Node.js is invoked (read more).
Tools official configuration support status (using latest versions at the time of writing):
Tool | Official Configuration Support | Implementation |
---|---|---|
ESLint | ESM/TypeScript | natively or with external utilities |
TypeScript | JSON | n/a |
Webpack | ESM/TypeScript | with external utilities |
Metro | CJS | n/a |
Jest | ESM/TypeScript | with external utilities |
Cypress | ESM/TypeScript | internally managed |
Babel | CJS/TypeScript | internally managed |
NOTE: In this section 'natively' means using Node.js native ESM/TypeScript support, without any external utility or wrapper. Also, we are referring to the official documentation of each tool. That doesn't mean that native support does not work (in practice we've found that Node.js native ESM/TypeScript support works well for many tools, even if official documentation does not mention it).
6. Expose executables without building, locally, and built ones when published
STATUS: NOT SUPPORTED ❌
{
"exports": {
".": {
"types": "./types/index.d.ts",
"development": "./src/index.ts",
"import": "./esm/index.js",
"require": "./cjs/index.js",
"default": "./cjs/index.js"
}
},
"files": ["types", "esm", "cjs"],
"bin": {
"my-dev-cli": "./src/bin/cli.ts"
}
}
Currently, there is no way to expose an executable such as my-dev-cli
in a conditional way similar to exports
. So, there is no easy way of using your executable locally from source code during development, while still allowing to use the built version when the package is published.
7. Does not ship broken configuration if published
STATUS: NOT SUPPORTED ❌
Publishing src
directory is not a good idea / best practice, so, considering the above package.json
configuration, "development": "./src/index.ts"
and "bin": "./src/bin/cli.ts"
are essentially broken when the package is published.
Proposal
Focusing on the problematic points above and ordered based on cost/benefit considerations:
1. Custom User Conditions Configuration
SUPPORTS: 2
, 3
, 4
, 5
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Native ESM loader (initialize and resolve)
We think it is fundamental to have some sort of support for easier configuration of custom user conditions within a project, making all invocations of Node.js use the specified custom user conditions along with default ones.
This simple feature would not only address the key limitations described above, but could also relieve tool authors of the ongoing burden of supporting custom user conditions, as they've had to do until now (see tool configuration examples above).
Implementing this would align Node.js to what users are already doing and expect from third-party tools.
With Package.json's conditions
field
{
"conditions": ["development"]
}
NOTE: There is already some work in progress in jiti to implement this.
The idea is for Node.js to just load the nearest package.json
file (relative tho cwd
) and use the eventual conditions
field to extend default conditions and those eventually already acquired from elsewhere (e.g., --conditions
).
With .noderc
CONDITIONS
option
CONDITIONS=['development']
(#53787)
2. Extension Aliases
SUPPORTS: 5
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Native ESM loader (initialize and resolve)
Similar to bundlers' and other third-party tools extension configuration, this would allow developers to use Node.js native TypeScript support without having to use .ts
extensions in their imports. This flexibility is already widely available, so it would be great to have it in Node.js as well.
This would avoid complex refactors for people that have been using .js
extensions for years, based on TypeScript recommendations (see point 5
above).
Could be implemented as an opt-in feature, so that it does not break existing code and does not create any additional overhead for those who do not need it.
In fact, it would be only a matter of looking up during resolution. Final file extension would be used for proper loading.
Implementing this would align Node.js to what users are already doing and expect from third-party tools.
With Package.json's extensions
field
{
"extensions": {
".js": [".js", ".jsx", ".ts", ".tsx"]
}
}
{
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
With .noderc
EXTENSIONS
option
EXTENSIONS=['.js', '.jsx', '.ts', '.tsx']
(#53787)
3. Conditional Executables
SUPPORTS: 6
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Unknown
{
"bin": {
"my-dev-cli": {
"development": "./src/bin/cli.ts",
"import": "./esm/bin/cli.js",
"require": "./cjs/bin/cli.js",
"default": "./cjs/bin/cli.js"
}
}
}
4. Exports Paths Fallbacks
SUPPORTS: 2
, 3
, 4
, 5
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Unknown
We know that "Exports is generally designed to resolve unambiguously without hitting the disk"". However, the benefits of having a fallback mechanism for exports
paths when the first one does not exist are not to be underestimated. We should consider implementing this feature in Node.js, even if in an opt-in way.
{
"exports": {
".": {
"types": "./types/index.d.ts|./src/index.ts",
"import": "./esm/index.js|./src/index.ts",
"require": "./cjs/index.js|./src/index.ts",
"default": "./cjs/index.js|./src/index.ts"
}
}
}
The following would conflict with existing validations and fallbacks specification (a.k.a., "alternative paths"):
{
"exports": {
".": {
"types": ["./types/index.d.ts", "./src/index.ts"],
"import": ["./esm/index.js", "./index/index.ts"],
"require": ["./cjs/index.js", "./index/index.ts"],
"default": ["./cjs/index.js", "./index/index.ts"]
}
}
}
See also: #58600
5. Executables Paths Fallbacks
SUPPORTS: 5
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Unknown
{
"bin": {
"my-dev-cli": "./cjs/bin/cli.ts|./src/bin/cli.ts"
}
}
{
"bin": {
"my-dev-cli": ["./cjs/bin/cli.ts", "./src/bin/cli.ts"]
}
}
6. Fields Safe-listing for Publishing
RESOLVES: 7
(see above)
NO BREAKING CHANGES
IMPACTED MODULES:
- Unknown
With Package.json's fields
field
Similar to files
but for safe-listing general package.json
fields, such as exports
, bin
, types
, etc.
{
"fields": ["extraField"]
}
{
"fields": {
"extraField": true,
"exports": ["types", "import", "require"]
}
}
With Package.json's conditions
field
{
"conditions": {
"dev": ["development", "types", "import", "require"],
"publish": ["types", "import", "require"]
}
}
Alternatives
Wrappers, shims and other hackish workarounds with the following drawbacks:
- Impact the developer experience.
- Potentially impact security.
- Non-standard and not future-proof.
- Not cross-platform.
- Somehow a shame, as Node.js has the potential to support all of this natively and heavy-lifting has already been done.
Honorable mentions:
Related work:
Final Words
We love Node.js. It has come a long way in recent years, becoming more flexible and powerful. With support for ESM, native TypeScript, and Corepack for managing modern package managers, the platform has evolved significantly. These improvements help developers keep up with changing patterns, such as bundling Node.js applications (now a growing trend)and the increasing adoption of monorepos for efficiency and a better developer experience.
At the same time, modern JavaScript development has grown increasingly abstract. Supersets, type-checking, linting, transpiling, polyfills, and support for multiple platforms (e.g., Node.js, React, React Native) have all added layers of complexity. As a result, "contour code" (non-application code and tooling) is becoming more intricate and harder to manage.
That’s why organizing this surrounding code for reuse and sharing, both within and across projects, while keeping type-safety, has become essential.
Every step we take toward that goal helps pave the way for truly modern projects: 100% ESM/TypeScript from configuration to implementation.
Metadata
Metadata
Assignees
Type
Projects
Status