Skip to main content

Implicit Libraries

Project Crystal

Since version 18 (and actually a little before that), Nx has been able to infer tasks based on the project structure. This means that Nx plugins can automagically add new targets (e.g. implicitly add a test target with the right configuration when it detects a Vitest configuration file). This is called Project Crystal.

Implicit Libraries

Implicit Libraries are a way to leverage Project Crystal to make an Nx library nothing more than a new folder in your workspace with an index.ts file at its root.

Here is an example of what a workspace looks like with Implicit Libraries compared to Explicit Libraries.

libs
└── web
├── cart
│ └── ui
│ ├── .eslintrc.json
│ ├── README.md
│ ├── project.json
│ ├── src
│ │ ├── index.ts
│ │ └── lib
│ │ ├── cart.spec.ts
│ │ └── cart.ts
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.spec.json
│ ├── vite.config.ts
│ └── vitest.config.ts
└── catalog
└── ui
├── .eslintrc.json
├── README.md
├── project.json
├── src
│ ├── index.ts
│ └── lib
│ ├── catalog.spec.ts
│ └── catalog.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
├── vite.config.ts
└── vitest.config.ts

The Boilerplate Problem

A common drawback when creating libraries is the boilerplate. Even though they are generally generated and taken care of by Nx (using generators and migrations), it can clutter the workspace and add some cognitive load.

Here is a typical non-buildable library structure:

libs/web/catalog/ui
├── .eslintrc.json
├── README.md
├── project.json
├── src
│ ├── index.ts
│ ├── lib
│ │ ├── my-lib.spec.ts
│ │ └── my-lib.ts
│ └── test-setup.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── vite.config.ts

Step 1: Shared Configuration Files

You will notice that many libraries share similar configuration files. The similarities are often per platform, but there could be other groupings.

Interestingly, most tools provide options that allow us to target specific folders and files (e.g. eslint [dir], vitest --root [dir], ...). This means that we could provide configurations that are shared by multiple libraries but use different options to target specific libraries.

For example, if you group your libraries per platform, you could move the configuration files to the common platform folder:

libs/web
├── .eslintrc.json 👈
├── catalog/ui
│ ├── README.md
│ ├── project.json
│ └── src
│ ├── index.ts
│ └── lib
│ ├── catalog.spec.ts
│ └── catalog.ts
├── cart/ui
│ ├── README.md
│ ├── project.json
│ └── src
│ ├── index.ts
│ └── lib
│ ├── cart.spec.ts
│ └── cart.ts
├── tsconfig.json 👈
├── tsconfig.lib.json 👈
├── tsconfig.spec.json 👈
└── vite.config.ts 👈

You will need to mainly adjust the paths in the configuration files. Here are some examples:

.eslintrc.json
- "extends": ["../../../.eslintrc.json"],
+ "extends": ["../../.eslintrc.json"]
tsconfig*.json
-  "extends": "../../../tsconfig.base.json",
+ "extends": "../../tsconfig.base.json",

- "exclude": ["./vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
+ "exclude": ["./vite.config.ts", "**/*.spec.ts", "**/*.test.ts"]
tip: some plugins will still add targets when project.json is found

If you enabled the @nx/eslint plugin (plugins: ["@nx/eslint/plugin"] in nx.json), the lint target will be added to both libraries even if there is no eslint configuration file in the library.

While we could add a test target to each library as shown below, this would defeat the purpose of implicit libraries.

project.json
"targets": {
"test": {
"command": "vitest",
"options": {
"cwd": "{projectRoot}",
"root": "."
}
}
}

Step 2: Implicit Library Inference

Thanks to Project Crystal, not only can we infer the targets we need (e.g. test) but we can also infer the libraries themselves. This means that we can remove all libraries' project configurations (i.e. project.json) and infer them dynamically.

First, you can prepare and simplify the workspace by:

  • removing project.json files from your libraries,
  • flattening the libraries content,
  • updating the tsconfig.base.json to point to the right paths (e.g. libs/my-lib/src/index.ts => libs/my-lib/index.ts).
tsconfig.base.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
- "@marmicode/web-catalog-search-ui": ["libs/web/catalog/search-ui/src/index.ts"],
+ "@marmicode/web-catalog-search-ui": ["libs/web/catalog/search-ui/index.ts"],
}
}
}

The workspace would look something like this:

libs/web
├── .eslintrc.json
├── catalog/ui
│ ├── README.md
│ ├── index.ts 👈
│ ├── catalog.spec.ts 👈
│ └── catalog.ts 👈
├── cart/ui
│ ├── README.md
│ ├── index.ts 👈
│ ├── cart.spec.ts 👈
│ └── cart.ts 👈
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── vite.config.ts

Now, you can create a workspace plugin that will add the libraries to the Nx graph.

tools/plugins/implicit-libs.ts
import { CreateNodesV2 } from '@nx/devkit';

export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
const [libs, platform, scope, name] = indexPath.split('/');
const projectRoot = `${libs}/${platform}/${scope}/${name}`;
const projectName = `${platform}-${scope}-${name}`;

return [
/* This is used by Nx to track which matching file was used by the plugin
* It is shown in the project detail web view. */
indexPath,
{
projects: {
/* This will add a project to the Nx graph for the detected library. */
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
},
},
},
];
});
},
];

Once you enable the plugin by adding it to your plugins in the nx.json file, you should see the libraries in the Nx graph (nx graph).

nx.json
{
"plugins": ["./tools/plugins/implicit-libs.ts"]
}
$ nx show projects

web-catalog-ui
web-cart-ui

Step 3: Debug the Plugin

The first time you try to debug the plugin, you might be surprised not to see the logs you produce in the console, nor to hit the breakpoints you set in your favorite IDE. This happens because the plugin runs in a separate background process (the Nx daemon).

While you can still find the logs in Nx logs file (i.e. .nx/workspace-data/d/daemon.log), it is not very convinient. In addition to that, the plugin is not executed on every command as the graph is cached.

tip: debugging the plugin

The easiest way to debug the plugin is by disabling the Nx daemon and enabling some additional Nx performance logging if needed.

NX_DAEMON=false NX_PERF_LOGGING=true nx show projects

Step 4: Add Targets to the Implicit Libraries

As we removed the project.json files, Nx plugins will not add any targets to the libraries. We have to adapt our plugin to add the targets we need.

tools/plugins/implicit-libs.ts
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...
return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
targets: {
lint: {
command: 'eslint .',
options: {
cwd: projectRoot,
},
},
test: {
command: 'vitest',
options: {
cwd: projectRoot,
root: '.',
},
},
},
},
},
},
];
});
},
];
info

Note that while we could technically hide the configuration files (e.g., .eslintrc.json, tsconfig*.json, vite.config.ts, etc.), doing so would break IDE support and other tools or plugins that rely on these files.

Step 5: Configure Caching

While the example above is a good start, it is still missing the caching configuration, but here is the good news: you can also infer the caching configuration.

tools/plugins/implicit-libs.ts
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...
return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
targets: {
lint: {
command: 'eslint .',
// ...
cache: true,
inputs: [
'default',
'^default',
'{workspaceRoot}/.eslintrc.json',
`{workspaceRoot}/${libs}/${platform}/.eslintrc.json`,
'{workspaceRoot}/tools/eslint-rules/**/*',
{
externalDependencies: ['eslint'],
},
],
outputs: ['{options.outputFile}'],
},
test: {
command: 'vitest',
// ...
cache: true,
inputs: [
'default',
'^production',
{
externalDependencies: ['vitest'],
},
{
env: 'CI',
},
],
outputs: [
`{workspaceRoot}/coverage/${libs}/${platform}/${name}`,
],
},
},
},
},
},
];
});
},
];
tip: where did the cache configuration come from?

As of today, there is no simple way of reusing Nx plugins logic in your own plugins.

A quick workaround is to give a look a the inferred project configuration before moving to implicit libraries (or using a sample Nx workspace):

nx show project my-lib --json | jq .targets.test

Otherwise, Nx plugins source code is a good source of inspiration (e.g. @nx/eslint or @nx/vite).

Step 6: Tag the Implicit Libraries

Finally, you can let the plugin tag the libraries based on the file structure convention.

tools/plugins/implicit-libs.ts
export const createNodesV2: CreateNodesV2 = [
/* This will look for all `index.ts` files that follow your file structure convention. */
'libs/*/*/*/index.ts',
(indexPathList) => {
return indexPathList.map((indexPath) => {
// ...

return [
indexPath,
{
projects: {
[projectRoot]: {
// ...
tags: [`platform:${platform}`, `scope:${scope}`, `type:${type}`],
},
},
},
];
});
},
];

Step 7: Add Libraries

You can now add new libraries by creating a new folder with an index.ts file following the file structure convention implemented in the Implicit Libraries plugin.

libs/
└── web 👈 platform
└── catalog 👈 scope
└── search-feature 👈 name-type (or just type)
└── index.ts

Then you can add the new library to the tsconfig.base.json paths.

tsconfig.base.json
{
"compilerOptions": {
"paths": {
...
+ "@marmicode/web-catalog-search-feature": ["libs/web/catalog/search-feature/index.ts"]
}
}
}

While Implicit Libraries make library generators less useful, implementing a generator could still be useful to:

  • make sure that libraries are created with the right structure and categories,
  • update the tsconfig.base.json paths,
  • update the .eslintrc.json with new boundaries when a library is using a new scope.

Step 8: Enjoy the Implicit Libraries

You can now enjoy your simplified workspace and verify the results using Nx console or by running the following command:

nx show project web-cart-ui --web

Implicit Library View

Full Plugin Example

Here is the full plugin example:

tools/plugins/implicit-libs.ts
import { CreateNodesV2 } from '@nx/devkit';

export const createNodesV2: CreateNodesV2 = [
'libs/*/*/*/index.ts',
(indexPathList[]) => {
return indexPathList.map(indexPath => {
const [libs, platform, scope, name] = indexPath.split('/');
const projectRoot = `${libs}/${platform}/${scope}/${name}`;
const projectName = `${platform}-${scope}-${name}`;
const nameParts = name.split('-');
const type = nameParts.at(-1);

return [
indexPath,
{
projects: {
[projectRoot]: {
name: projectName,
sourceRoot: projectRoot,
projectType: 'library',
tags: [`platform:${platform}`, `scope:${scope}`, `type:${type}`],
targets: {
lint: {
command: 'eslint .',
options: {
cwd: projectRoot,
},
metadata: {technologies: ['eslint']}
cache: true,
inputs: [
'default',
'^default',
'{workspaceRoot}/.eslintrc.json',
`{workspaceRoot}/${libs}/${platform}/.eslintrc.json`,
'{workspaceRoot}/tools/eslint-rules/**/*',
{
externalDependencies: ['eslint'],
},
],
outputs: ['{options.outputFile}'],
},
test: {
command: 'vitest',,
metadata: {technologies: ['vitest']}
options: {
cwd: projectRoot,
root: '.',
},
cache: true,
inputs: [
'default',
'^production',
{
externalDependencies: ['vitest'],
},
{
env: 'CI',
},
],
outputs: [`{workspaceRoot}/coverage/${libs}/${platform}/${name}`],
},
},
},
},
}
];
});
},
];
warning

Note that this is a simplified example. You might need to adjust it to your needs.

👉 You can find a more complete example on the Cookbook Demos repository. 👈

When to use Implicit Libraries?

While implicit libraries can be a powerful way to:

  • 👍 simplify your workspace,
  • 👍 get more control over the configuration,
  • 👍 share configuration files,
  • 👍 ensure that the workspace is highly aligned with the architecture style and the file structure convention,

they come with some drawbacks like:

  • 🫤 having to implement plugins,
  • 🫤 and losing Nx builtin plugins inference.
info

That is why our general recommendation is to mainly use Implicit Libraries for non-buildable libraries.

tip: Implicit Libraries encourage modular architectures

From what we observed, the amount of boilerplate generated by libraries tends to discourage teams from creating additional libraries. Implicit Libraries can help teams create more libraries and easily maintain them, leading to a more modular architecture.

Source Code

Additional Resources