RFC: Linking Packages with Workspaces (aka New TypeScript Setup) #29099
Replies: 9 comments 26 replies
-
I'd like to check out my use case (graph is trimmed down for clarity). Currently,
When I want to launch a debug session, I simply forward a Now correct me if I'm wrong, with the new setup I'll have to:
Will I still be able to simply run Note that personally, the separation between |
Beta Was this translation helpful? Give feedback.
-
First of all, I love this RFC, thanks a lot for the work you put into it! In the past, I use to rely heavily on tsconfig path resolution, but in the last year I shifted to the approach you are mentioning (more specifically, One big feature you are not talking about is multiple version of third party. When working on legacy systems, using ts path resolution was always a huge challenge to align versions of third party, and the price to entry was huge. With this, it opens the door to more incremental adoption within a system. However, the dependancy check rule from Nx eslint plugin is not working well since it assumes there is only one version of a given dependancy in the monorepo. With this change, this assumption is no longer applying Having incremental build also allows for incremental migration where config are not uniform yet ! One question, do you think infinite tasks will land as a requirement of this new workspace linkage ? One of the biggest pain point we have is to have to spin up Lastly, regarding generating buildable libraries, especially with vite, are you considering adding a plugin such as vite-plugin-externalize-deps ? Currently, the generated vite config only exclude (for react) react, react-dom and the react runtime. However, if you are moving toward more incremental builds, with package.json references, without this plugin that means bundling every single dependancies on the project into the bundle. This could lead to issues like react context not properly linked and bundle size issues
This RFC is a great way to explain it yes. However, there might be some documentation missing around how the composite TS build works (maybe I missed it ?)
Like mention, with infinite tasks, it solves a large part of the current DX drawbacks
This is something that is deeply missing, currently the only way to migrate to the new typescript setup is to generate a new workspace and figure out what makes it different. Having a guide (and maybe a generator in the future) is something that would be welcome! Furthermore, this means changing for project:
One last thought, I think this option should be enabled by default on package resolution monos:
On a integrated monorepo, an update on the lockfile only means "a third party was updated", however in package resolution, an update on a lockfile also mens "a internal package consumes an internal package update" given the dependancies of internal packages are also track in lockfiles, meaning that affected is wrongly behaving (treating lockfile as a global update) and thus, have worse caching performance Overall, I think it's a great move to a more standard and less magic way of scaling monorepo |
Beta Was this translation helpful? Give feedback.
-
This is fantastic! I love the idea of removing some of the magic, even if Nx is amazingly magical ;) Couple of questions Lib generation path for non-buildable libsIn the RFC you say this:
But in the Could you clarify that a bit please and thank you? (Note: I have never used workspaces with any package manager so I may be missing a key piece of information here). Migration pathI know its not explicitly mentioned but hoping that there is planned to be a migration path sometime close to the release of this feature? I don't want to get super excited on it but then not be able to migrate to it. I know you all said it is going to be pretty complex to migrate existing workspaces (ours was generated forever ago as you can probably tell by the PerformanceIt is obviously very early days, and I realize things will change and it will be hyper dependent on the repo and all that but whats the performance difference between Thank you all for the work on this RFC and special thanks to @jaysoo for writing it up, excited for the future! |
Beta Was this translation helpful? Give feedback.
-
The new setup is certainly more standardized, but it introduces additional complexity compared to the old one. For instance:
From a developer experience perspective, I’m not convinced it lowers the barrier for newcomers. However, I do believe it establishes a stronger foundation for large monorepos, offering better performance and more clearly defined project boundaries. Overall, it seems like a reasonable tradeoff. That said, I have two main concerns:
Clear answers to these questions would greatly simplify the transition. |
Beta Was this translation helpful? Give feedback.
-
Thanks for the RFC, it is detailed and very helpful. I have a question regarding Angular: I've not seen the use of project references for Angular apps or libraries. From my look, it is not supported - see angular/angular#37276. Do you have an approach in mind? |
Beta Was this translation helpful? Give feedback.
-
This sounds promising ... PNPM has a concept of catalogs (https://pnpm.io/catalogs) would we be able to leverage this to help manage versions in one place and or incrementally upgrade frameworks like React ? |
Beta Was this translation helpful? Give feedback.
-
This is an excellent effort going in the right direction (IMO). Thanks! One question. In the past, via tspath, we were accustomed to creating a large import path for the entry point of a library. The folder structure often guided the convention for the library name, import path, and tags. However, by protocol, the package.json only supports one forward slash in the import path. good: @acme/utils How can we migrate to the workspace structure following the old conventions? Example of previous conventions: // located at packages/portal/auth/server/domain/src/index.ts
// with tags application:portal, subdomain:auth, technology:server, type:domain
import { ... } from '@acme/portal/auth/server/domain'; |
Beta Was this translation helpful? Give feedback.
-
Use case: Normal Angular app
So now i already have:
And as i understand with this new way i will receive extra bonus - each time i will generate new library i will need to stop running apps and re-run npm install for 10 seconds? 🤣 If somebody want to tell me how im using it wrong - please write down, will be happy to find right way, because now im thinking about splitting it to just simple standalone apps |
Beta Was this translation helpful? Give feedback.
-
Will it be possible to use a hybrid approach? Using the new package.json node resolution approach for some packages and the integrated workspace setup with path aliases for other projects in one monorepo? |
Beta Was this translation helpful? Give feedback.
-
Author: Jack Hsu
Published: 2024-11-27
Updated (2024-11-28): Add more information on how linking works, and how pnpm requires libraries to be specified as dependencies in
package.json
. Added the benefit of opting out of Single Version Policy in the intro. Added a note in intro that the old setup will continue to work even after this change.This RFC proposes a transition from using
tsconfig.base.json
compilerOptions.paths
for linking TypeScript packages to using the workspaces feature available in package managers like npm, yarn, pnpm, and bun. The transition also includes adding the@nx/js/typescript
plugin to keep TypeScript project references in sync (vianx sync
) and infer atypecheck
target for all TypeScript projects.We will focus on application development (both web and backend), and we are collecting feedback on the DX differences between the old (aka "integrated") and the new TypeScript monorepo setup.
The goal is to modernize our TypeScript monorepo setup, while retaining what makes Nx development great for application development. Some of benefits of the new setup include:
nx init
into most existing workspaces and use@nx/react
and other plugins.@/
to be used in each application. This is popular in Next.js and Remix projects.The main impact will be for application developers that have buildable libraries used by their apps. There will no longer be an option to bundle buildable libraries from source.
Also, note that the old setup will continue to work. Our generators will continue to support tsconfig paths, so there isn't a need to migrate to the new setup if the old one works well for you.
Terminology
compilerOptions.paths
intsconfig.base.json
, which is currently the default.npx create-nx-workspace --preset=ts
.build
target. Consumers of the library are projects within the monorepo.build
target and can be published to a registry. Consumers or the library can be within the monorepo and external to the monorepo.Detailed Explanation
Current Setup
In the current monorepo, when you create a library such as the following:
npx create-nx-workspace acme --preset=react-monorepo cd acme npx nx g @nx/react:lib packages/foo
You will see an entry in
tsconfig.base.json
that maps@acme/foo
to an internal path within the monorepo.This allows TypeScript and bundlers to map imports from
@acme/foo
to their source. There are some downsides:package.json
is not the only source of truth, and developers must add an entry totsconfig.base.json
as well to point to the correct source@/
alias, popular in Next.js, Remix, etc. since the localpaths
completely overrides the ones fromtsconfig.base.json.
Proposed Setup
We propose switching to the workspaces feature provided by package managers. This would involve:
Workspaces: Adding a
workspaces
field inpackage.json
:or the equivalent configuration for other package managers like pnpm and bun.
Dependency Linking: The package manager will link libraries in the monorepo during install (e.g.
npm install
).TypeScript and bundlers will resolve imports from
@acme/foo
using Node resolution rather than throughcompilerOptions.paths
.We'll also add
@nx/js/typescript
plugin tonx.json
, which syncs project references and infers atyepcheck
target on TypeScript projects.Feedback Requested
We are collecting feedback on:
Let's examine four development workflows :
And also look at two changes:
dist
are no longer copied to{workspaceRoot}/dist
but remain in the projects root. This mostly affects published libraries.Use-Case: Web Applications Using Non-Buildable Libraries
When generating libraries for web applications, we will default them to be non-buildable. More specifically, this refers to the following generators:
@nx/react:library
@nx/next:library
@nx/remix:library
@nx/expo:library
@nx/react-native:library
@nx/angular:library
@nx/vue:library
@nx/nuxt:library
We no longer generate a
project.json
, and instead apackage.json
will be added. For example, if you runnx g lib packages/foo
thenpackages/foo/package.json
looks as follows:Libraries no longer have the Nx-specific
project.json
file, and instead we have thepackage.json
file that standard in JS projects. This could be a breaking change for custom generators that assume the existence ofproject.json
, but can be mitigated through the use of project utils from@nx/devkit
(e.g.updateProjectConfiguration
).After library is generated, Nx runs
install
(e.g.npm install
) to update the workspace. For npm, yarn, and bun, the new package will be under the rootnode_modules/@acme/foo
so Node resolution works. For pnpm, this is not the case, and we have two options to link the new package:package.json
asdevDependencies
so the library will be under the rootnode_modules
and available for applications to use.package.json
(e.g.apps/demo/package.json
) and runspnpm install
.(1) is preferred to avoid the user having to manually update files before consumption of the new library. It is also technically incorrect to add the library to the application's
package.json
file since it will be bundled into the production output, thus not a dependency to run.The
main
andtypes
fields point to the source./src/index.ts
file. This means that both TypeScript and bundlers will resolve to the source file using Node resolution, and we no longer needtsconfig-paths
or similar plugins for bundlers to resolve non-buildable libraries.To add a deep import path (e.g.
@acme/foo/deep
), previously you would add totsconfig.base.json
:In the new setup, you add an
exports
field topackage.json
. This is a standard Node feature and follows Node resolution that is supported in all bundlers (and tsc). We encourage not using wildcards, and be explicit about public API like so:This means potentially more entries required for deep imports, but has the advantage of creating stronger module boundaries.
Note: There is a strict requirement for libraries to be non-buildable if you want both TypeScript and bundlers to refer to source. If you make the library buildable in the future, you will be forced into incremental builds, as we'll cover in the next section.
Before you can commit and push your branch, you'll need to run
nx sync
so Nx can update the application's references (e.g.apps/demo/tsconfig.json
). Otherwise, CI will fail ontypecheck
. This reference will also be updated when you runbuild
ortypecheck
targets for any project.There are two options to avoid running
nx sync
manually:postinstall
script tonx sync
, which will happen when the new non-buildable library is generated.nx sync
after library generator is done, as aGeneratorCallback
.(2) is preferred so we don't need additional package scripts.
Use-Case: Web Applications Using Buildable Libraries (e.g. Incremental Builds)
This affected generators are the same as the previous non-buildable section:
@nx/react:library
@nx/next:library
@nx/remix:library
@nx/expo:library
@nx/react-native:library
@nx/angular:library
@nx/vue:library
@nx/nuxt:library
Also note that the generic
@nx/js:library
generator will continue to default to creating buildable libraries. Thus, anyone who is used to runningnx @nx/js:library
needs tonone
when prompted for bundler, or pass--bundler=none
to the generator in order to avoid incremental builds.We will no longer generate
project.json
, and will continue to generate apackage.json
for the library with the additionalnx
field to mark it as an Nx-project. For example,nx g lib packages/foo
createspackages/foo/package.json
as follows:Just like non-buildable libraries, Nx runs
install
(e.g.npm install
) to update the workspace. For npm, yarn, bun, the new library is linked in the rootnode_modules
and is available for consumption. For pnpm, we have the same two options as non-buildable libraries:package.json
'sdevDependencies
so the library exists in the rootnode_modules
.package.json
file and runspnpm install
again.Same as the previous section, we're leaning towards (1) for the best DX.
In this setup, there will be a
build
target for the library, and developers must build the library prior to consumption by the application.That means if you have an application at
apps/demo
with app component (apps/demo/src/app.tsx
) like so:Then before serving the app, you must also run
nx build foo
or else you will get an error thatpackages/foo/dist/index.js
does not exist, which is the main entry as defined inpackages/foo/package.json
.We have a few options to help alleviate this pain point:
^build
to the serve target'sdependsOn
insideapps/demo/package.json
, which means that serve will never error out due to missingdist
. This will need to be configured for each app, so could lead to duplicated configs.^build
to thetargetDefaults
of serve innx.json
, which means all serves will run build of their dependencies. This means we may applydependsOn
for projects that does not need it.build
of the libraries before serving the app.(3) is consistent with non-Nx monorepo setup, but we're leaning towards (1) so all targets work out of the box, but we keep the config scoped to the projects that need it. The infinite task / sidecar RFCs (linked at the bottom) could be an option as well when they land.
Note that we will not watch changes to the library, so users will need to run the library build in order for changes to reflect in the application. For example,
nx serve demo
, which will runnx build foo
(and build for other deps) first, then start dev server.packages/foo/src/index.ts
nx build foo
.The same requirement to run
install
andnx sync
exist as the first use-case.Use-Case: Node Applications Using Non-Buildable Libraries
This workflow is for the following generators:
@nx/js:library
@nx/node:library
The
@nx/js:library
generator currently generates a buildable library by default, whereas@nx/js:node
generates a non-buildable library. In order to ensure users get the best experience as possible, we will change@nx/js:library
to create non-buildable libraries by default as well.In this case, if your application is using a bundler (e.g. esbuild, webpack), then we will continue to support bundling in non-buildable libraries. However, if you transpile using
tsc
orswc
then you must use buildable libraries and have to use incremental builds (as covered in the next section), and we will not provide any solution to import non-buildable libraries withtsc
. The requirement to use buildable libraries fortsc
andswc
is different from web applications, where all bundlers will support building from source.We no longer generate a
project.json
, and instead the configuration goes under annx
entry inpackage.json
. If you have a custom generator that assumesproject.json
then you will need to update it to use project utils from@nx/devkit
. Deep imports are handled the same way as non-buildable libraries for the web. For example, if you runnx g lib packages/util
then thepackage.json
may look as follows with deep imports:The
exports
are encouraged to be more specific rather than using a wildcard compared to the old setup (e.g."@acme/util/*": "packages/util/src/*"
incompilerOptions.paths
). This enables stricter module boundaries for the public API.The same requirement to run
install
andnx sync
exist as the first use-case (web application with non-buildable libraries). Nx could run both automatically as aGeneratorCallback
.Use-Case: Node Applications Using Buildable Libraries (Incremental Builds)
This workflow is for the following generators:
@nx/js:library
@nx/node:library
The generators will not create buildable libraries by default so users have to opt in. Similar to web applications, buildable libraries only works with incremental builds. There is no longer an option to build libraries from source, unless you choose non-buildable libraries (previous section).
Note: as mentioned in the previous section,
@nx/js:library
will no longer generate buildable libraries by default. You will need to choose a bundler other thannone
to make it buildable.The initial serve will fail if dependencies are not built. So if the user has an application and library generated as follows:
And then consumes
@acme/util
fromapps/api/src/main.ts
as follows:Then, before serving the API, you need to run
nx build util
first. The same options as web applications exist to improve this experience:^build
to the serve target'sdependsOn
insideapps/demo/package.json
, which means that serve will never error out due to missingdist
. This will need to be configured for each app, so could lead to duplicated configs.^build
to thetargetDefaults
of serve innx.json
, which means all serves will run build of their dependencies. This means we may applydependsOn
for projects that does not need it.build
of the libraries before serving the app.Again, we are leaning towards (1) for now. The infinite task / sidecar RFCs (linked at the bottom) could be an option as well when they land.
That means, the initial serve works, but subsequent changes to the buildable libraries are not watched.
nx serve api
, which will runnx build util
(and build for other deps) first, then start API.packages/util/src/index.ts
nx build util
.watch: true
(@nx/js:node
executor option) for the serve target. Otherwise, you must kill and restart the server.Also note that
project.json
will not be created for the application and library. Instead, anx
entry is added to thepackage.json
file to provide Nx configuration. Again, this could have implications on custom generators that assume existence ofproject.json
file. The library'spackage.json
will point to the build artifacts, just as they do for buildable web libraries.The same requirement to run
install
andnx sync
exist as the first use-case (web application with non-buildable libraries). Nx could run both automatically as aGeneratorCallback
.Use-Case: Typechecks
In the new setup, the
@nx/js/typescript
plugin inferstypecheck
targets for all included TypeScript projects. This means that typechecks can happen in two places:nx run-many -t typecheck
is runnx run-many -t build
is run (assuming the underlying tool, such as Webpack/Rspack, is configured to typecheck)In the old setup, builds perform typechecks by default, since we don't a separate
typecheck
target for every project. In the new setup, we want to update application and library generators for the following stacks such at builds no longer do typechecking by default:If you rely on
build
in CI to also typecheck, then this will no longer be the case. Instead, CI scripts should also includetypecheck
target.Note: This will affect
nx g ci-workflow
since we should also includetypecheck
by default.Use-Case: Published Libraries and Output Files
In the old setup, we always point the output folder for
build
to{workspaceRoot}/dist
by default. This setup also came with asset management, like generating/copyingpackage.json and
README.md` files to the output folder.In the new setup, the
dist
folder is moved to within the project root. This is required to allow linking to work via workspaces. Thus, after a build you will have something like this:The contents of the source
package.json
must in a publishable state. That is, correctversion
,files
, etc. There is no asset management viageneratePackageJson
orassets
options like in the old setup. You are expected to ensure that the project root contains all the publishable artifacts.Timeline
How to Provide Feedback
Please provide your feedback by commenting on this RFC directly. If needed we can spin up additional discussions from the comments.
Other resources
Beta Was this translation helpful? Give feedback.
All reactions