This article is part of the TypeScript Project References series:
- Everything You Need to Know About TypeScript Project References
- Managing TypeScript Packages in Monorepos
- A new Nx Experience For TypeScript Monorepos
Today we're excited to release a brand new experience for Nx workspaces. Historically, Nx, and many other monorepo tools, have relied on TypeScript's path aliases to connect your many packages to one another. While this approach can work, it does come with some overhead. Apart from runtimes and bundlers requiring special handling, the main limitation is in large monorepos. We've seen larger organizations struggle with slowness, memory issues and editors not being able to properly resolve symbols.
This is why we're releasing a new NPM/Yarn/PNPM/Bun workspaces-based setup combined with TypeScript project references. The new setup is faster, more efficient in terms of memory use, and fixes common issues with TypeScript editor support for large monorepos.
For existing Nx users: Don't worry as we're not going to deprecate the current TypeScript alias based setup. We are going to have a migration guide and potentially tooling to automate some of it. More about that later in the article. Also, for now the new workspaces based setup is behind a --workspaces
flag as we're gathering feedback and polishing it further.
Ready? Let's dive in!
Let's Check Out the New Experience
The workspaces experience is currently available behind the --workspaces
flag. This allows us to gather your feedback and polish it further before rolling it out as the default.
To try it, you can create a new workspace with:
❯
npx create-nx-workspace tsmono --preset=react --workspaces
The exception is if you use
--preset=ts
or if you usenx init
to add Nx to an existing NPM/Yarn/PNPM/Bun workspaces monorepo. In those situations you are already in a NPM/Yarn/PNPM/Bun workspaces environment and you likely know what's going on.
Create a New Workspaces-based Monorepo
Let's start with a simple workspace setup without any framework or specific tech stack, just TypeScript support. This allows us to highlight how features can be incrementally added over time. Run the following command:
❯
npx create-nx-workspace tsmono --preset=ts
This generates a NPM workspaces setup. The only traces of Nx are:
- a
nx.json
at the root containing configuration about caching and task pipelines - a
nx
dependency in the root-levelpackage.json
- a
@nx/js
dependency in the root-levelpackage.json
; this is because we created a TypeScript monorepo setup which ships with support for creating and managing TS based packages.
Note if you want to create a PNPM/Yarn/Bun workspace use the
--pm
flag to specify your preferred package manager.
Creating a New Package
Nx has always shipped with code generators, allowing you to scaffold projects or integrate features into existing ones. These generators are part of Nx plugins and have been adapted to work seamlessly in NPM, Yarn, PNPM, or Bun workspaces.
While you could create a package manually, the generator simplifies the process by handling all the tedious setup. It automatically:
- Configures the
package.json
file, including proper exports. - Sets up
tsconfig
files to work well with your editor and TypeScript project references for fast incremental type checking.
Run the following command to generate a new TypeScript package:
❯
npx nx g @nx/js:lib packages/mytslib
During the setup, you can choose whether to include a bundler like tsc
or leave it out if the package is intended for local use only. You can also configure optional features like linting or testing (with Vitest or Jest).
The generator configures the package.json
file just as you would manually, ensuring it includes entries for type
, main
, types
, and exports
.
Where are the package.json
scripts though? Although you could define package.json
scripts and Nx would use them, this isn't required. Nx can automatically detect the underlying tool that is being used - in this case TypeScript - and infers a typecheck
command. You can simply run the following and it would work:
❯
npx nx typecheck mytslib
If you selected additional features like a bundler, linter, or testing framework during generation, Nx would also infer corresponding commands, such as build
, lint
, or test
.
Let's create an application next to use our package.
Generating a New Vite-based React Application
While you could use the Vite CLI to generate a new Vite app in your monorepo, the Nx React plugin adds some valuable features. It configures TypeScript project references and ensures Vite works seamlessly within a monorepo setup.
To begin, install the Nx React plugin:
❯
npx nx add @nx/react
This command installs and configures the plugin. Once installed, you can use its application generator to scaffold a new React application:
❯
npx nx g @nx/react:app apps/myviteapp
Importing Packages in our React Application
To use the mytslib
package in the newly generated React application, simply import it as follows:
1import NxWelcome from './nx-welcome';
2
3import { mytslib } from '@tsmono/mytslib';
4
5export function App() {
6 console.log(mytslib());
7 return (
8 <div>
9 <NxWelcome title="@tsmono/myviteapp" />
10 </div>
11 );
12}
13
14export default App;
15
Thanks to the TypeScript project references created by Nx during project generation, your editor will fully support features like autocomplete and navigation. For instance, the root-level tsconfig.json
references both the myviteapp
and mytslib
projects which allows the editor to correctly find all the available projects in your monorepo.
1{
2 "extends": "./tsconfig.base.json",
3 "compileOnSave": false,
4 "files": [],
5 "references": [
6 {
7 "path": "./packages/mytslib"
8 },
9 {
10 "path": "./apps/myviteapp"
11 }
12 ]
13}
14
Automatically Syncing TypeScript Project References
TypeScript project references offer significant benefits for large-scale monorepos, particularly in terms of compilation speed and memory usage. However, their maintenance can become a challenge as your monorepo evolves. Every project's tsconfig.json
needs to stay updated with the correct references to its dependencies, effectively requiring you to manually manage the TypeScript project graph.
To address this, Nx introduces the nx sync
command, which automatically keeps your TypeScript project references in sync. Whenever you run commands like build
, serve
, or dev
, Nx verifies whether the project references are accurate. If they are out of sync, it prompts you to update them.
For example, after importing mytslib
into myviteapp
, you'll notice that apps/myviteapp/tsconfig.json
now includes a reference to mytslib
, ensuring your project remains correctly configured:
Watching Dependencies
In our current setup, the mytslib
package is not “buildable”. Notice it directly exports the TypeScript files. This means when myviteapp
imports mytslib
, it directly imports the TypeScript files and handles the transpilation. As a result, if we serve the application in dev mode, any changes to mytslib
will automatically update the application because it depends on the source files directly.
However, in some cases, you may have buildable libraries. These are useful for caching, CI optimizations, or releasing packages outside the monorepo. When a buildable library changes, it must be recompiled to reflect the updates. To streamline this process, Nx introduces a watch-deps
target, which automatically watches and rebuilds the dependencies of your applications.
To simulate this, let's create a buildable library:
❯
npx nx g @nx/js:lib packages/buildablelib --bundler=tsc
Next, import it in our existing mytslib
:
1import { buildablelib } from '@tsmono/buildablelib';
2
3export function mytslib(): string {
4 return buildablelib() + ' - mytslib';
5}
6
Now, open two terminal windows. In the first run the watch-deps
command:
❯
npx nx watch-deps myviteapp
In the second, serve the Vite application:
❯
npx nx serve myviteapp
The watch-deps
target takes care of automatically watching all dependencies of myviteapp
and rebuilding them automatically when anything changes.
Key Highlight - Locality and Minimalism
Nx plugins have been essential for helping enterprises organize and scale monorepos, but their reliance on Nx-generated workspace structures made migrating existing setups difficult. To address this, we introduced inferred tasks, which allows plugins to read directly from underlying tool configurations, like vite.config.ts
, enabling seamless integration with existing projects.
We further improved how plugins work by applying the locality principle. Originally, plugins made many assumptions about the workspace structure and relied heavily on global configuration files. We updated them to operate locally within the project whenever possible, minimizing changes to global configurations.
In addition, we are now focusing on creating a minimal setup that you can expand incrementally as needed. The idea is to keep the initial setup for new projects as simple as possible, closely mirroring what you would manually configure yourself. The generated code follows best practices:
- Configuring
package.json
files correctly with exports - Setting up
tsconfig
files to work optimally in editors - Creating TypeScript project references and keeping them in sync
Nx is also much more versatile. It can directly run your package.json
scripts or infer targets based on the tools you're using.
You still also have the option to create Nx's project.json
files (see docs) for declaring more complex configurations. However, you only use them if needed.
The key idea here is that tooling shouldn't get in your way but should elevate your experience.
Key Highlight - Performance
We have already highlighted some raw metrics from benchmarking TypeScript project references compared to running type checking globally for the entire monorepo. Check out our first article in the series for more details.
The core difference to understand here is that traditionally, you might have a single task that combines building and type checking, or at least two separate ones (like for Vite applications): one for building and one for type checking. For large TypeScript monorepos, this approach can become problematic because type checking occurs at the application level for all projects in the repository. This means a single TypeScript program is responsible for the entire process.
This has two major implications:
- It cannot be parallelized and distributed on CI, which makes it slow.
- It is memory-intensive, requiring large CI machines (resulting in higher costs).
By leveraging the new Nx and TypeScript project references setup, we now have fine-grained, individual type checking tasks at the project level. This allows you to run tasks in parallel and potentially distribute them across different Nx Agents on CI.
As such, the new approach:
- Has fine-grained type checking tasks, which can be run in parallel.
- Is distributable across multiple machines, resulting in faster CI runs.
- Each individual type check requires less memory, allowing the use of smaller machines.
- Each individual type check can be cached, significantly speeding up follow-up runs.
We ran some benchmarks comparing the current Nx setup (not using TS project references) and the new TypeScript project references-based setup. TL;DR, here are some high-level numbers:
- Setup without TS project references: ~8 min
- New TypeScript project references setup: ~2 min
You're seeing a ~
in front because it might fluctuate slightly (within ~20 sec) depending on how many machines we use and the distribution of tasks. Let's break it down. I'll refer to "Old" as the setup without TypeScript project references and "New" as the setup using TypeScript project references.
3 Agents - Old: 8m 38s; New: 2m 49s
We just have two tasks running here: demo:build
and demo:typecheck
. Tasks are distributed across two agents, while one agent remains unused.
If we run the same setup but leverage the new fine-grained typecheck
tasks, we can see how the distribution is much more optimal. Now, all three agents are utilized, reducing the runtime to approximately 3m 29s, which corresponds to the slowest agent.
Let's explore whether increasing the number of agents further improves the overall CI runtime.
New setup, 4 Nx Agents - 3m 02s
Beyond four agents, we won't be able to go any faster because the slowest, non-splittable, and thus non-distributable task (huge-lib:typecheck
) takes approximately 2m 32s.
And note, this is the worst-case scenario, relying solely on distributing type-checking tasks across different machines. Subsequent runs might hit the cache, making them even faster.
It's also important to emphasize that the main reason we can fully leverage these benefits from TypeScript project references is that we avoid the maintenance burden of setting them up manually, thanks to the automated Nx sync command.
Now, if your company struggles with these performance issues in large TypeScript monorepos, let us know. We’ve worked with many teams to solve similar challenges and would be happy to help. Reach out!
FAQ
Here are some common questions and corresponding answers.
Nx by default creates a TypeScript Path Aliases based Setup. Is that Deprecated?
No, it's not deprecated. When Nx was first introduced, workspaces-based setups weren't yet an option. TypeScript path aliases were the go-to solution for creating modular monorepos, offering a simple yet effective way to structure projects. Nx plugins handled the complexities behind the scenes, enabling incremental compilation by rewriting paths on the fly.
Going forward, the recommended approach is to use NPM, Yarn, PNPM, or Bun workspaces combined with TypeScript project references. This setup integrates more seamlessly with modern tooling and can potentially lead to a more performant setup.
In fact, Nx has supported running tasks and caching package.json
scripts in workspace setups for years. The main limitation was the compatibility of Nx plugins, which has now been addressed. Plugins work equally well in both scenarios: the workspaces setup and the TypeScript path alias setup.
Will There be a Migration Path from the Current Nx Setup?
Yes, it's already possible to use Nx in a mixed setup that combines NPM, Yarn, or PNPM workspaces with TypeScript path aliases. However, doing so requires careful attention and an understanding of how package resolution works.
As we continue refining the new workspaces experience, we're also preparing detailed content and guidance to support an incremental migration, making the transition as smooth as possible.
What Are the Implications for a Single Version Policy?
We've consistently advocated for a single version policy in Nx monorepos and continue to believe it's the better approach in the long run.
The TypeScript path alias-based setup inherently enforces a single version policy, making it harder to opt out. In contrast, the workspaces-based monorepo configuration provides more flexibility. That said, you can still adopt a single version strategy with workspaces, and we may introduce tooling in the future to help enforce it.
What About Angular? Does the New Setup Work with Angular?
Currently, Angular and its underlying compiler don't support TypeScript project references. However, you can still use Nx and Angular within an NPM, Yarn, or PNPM workspaces-based setup. This can be especially useful when migrating multiple Angular applications into a single Nx monorepo.
For now, we continue to use the TypeScript path alias-based setup for pure Angular monorepos, as it offers a better developer experience in the current context. That said, we're actively working on improving this. Large Angular applications stand to benefit significantly from the performance and memory optimizations enabled by TypeScript project references.
Wrapping up
That’s it! Try it out and let us know what you think. If you encounter any issues or have questions, don’t hesitate to reach out. We want to catch all edge cases before making this the default for new Nx workspaces.
If you’re working in a large monorepo and struggling with the performance issues we discussed, reach out. We’ve helped many teams tackle similar challenges and would be happy to assist.