Skip to content

Building and Testing TypeScript Packages in Nx

This tutorial walks you through creating a TypeScript monorepo with Nx. You'll build a small example project to understand the core concepts and workflows.

What you'll learn:

  • How to structure multiple TypeScript packages in a single repository
  • How Nx caching speeds up your local development and CI pipelines
  • How to run builds and tests efficiently across multiple packages
  • How to share code between packages using local libraries
  • How to fix CI failures directly from your editor with Nx Cloud

This tutorial requires Node.js (v20.19 or later) installed on your machine.

Step 1: Creating a new Nx TypeScript workspace

Section titled “Step 1: Creating a new Nx TypeScript workspace”

Run the following command to create a new Nx workspace with the TypeScript template:

Terminal window
npx create-nx-workspace@latest my-nx-repo --template=nrwl/typescript-template

Or create your workspace in the browser with CI pre-configured.

Once the workspace is created, navigate into it:

Terminal window
cd my-nx-repo

Let's take a look at the structure of our new Nx workspace:

  • Directorymy-nx-repo/
    • Directorypackages/
      • Directoryasync/
      • Directorycolors/
      • Directorystrings/
      • Directoryutils/
    • eslint.config.mjs
    • nx.json
    • package-lock.json
    • package.json
    • tsconfig.base.json
    • tsconfig.json
    • vitest.workspace.ts

The nx.json file contains configuration settings for Nx itself and global default settings that individual projects inherit.

Now, let's build some features and see how Nx helps get us to production faster.

Let's create two TypeScript packages that demonstrate how to structure a TypeScript monorepo. We'll create an animal package and a zoo package where zoo depends on animal.

First, generate the animal package:

Terminal window
npx nx g @nx/js:lib packages/animal --bundler=tsc --unitTestRunner=vitest --linter=none

Then generate the zoo package:

Terminal window
npx nx g @nx/js:lib packages/zoo --bundler=tsc --unitTestRunner=vitest --linter=none

Running these commands should lead to new directories and files in your workspace:

  • Directorymy-nx-repo/
    • Directorypackages/
      • Directoryanimal/
      • Directoryzoo/
      • ...
    • vitest.workspace.ts

Let's add some code to our packages. First, add the following code to the animal package:

packages/animal/src/lib/animal.ts
export function animal(): string {
return 'animal';
}
export interface Animal {
name: string;
sound: string;
}
const animals: Animal[] = [
{ name: 'cow', sound: 'moo' },
{ name: 'dog', sound: 'woof' },
{ name: 'pig', sound: 'oink' },
];
export function getRandomAnimal(): Animal {
return animals[Math.floor(Math.random() * animals.length)];
}

Now let's update the zoo package to use the animal package:

packages/zoo/src/lib/zoo.ts
import { getRandomAnimal } from '@org/animal';
export function zoo(): string {
const result = getRandomAnimal();
return `${result.name} says ${result.sound}!`;
}

Add the @org/animal dependency to zoo's package.json (use * for npm or workspace:* for pnpm/yarn):

packages/zoo/package.json
{
"dependencies": {
"@org/animal": "*"
}
}

Then link the packages:

Terminal window
npm install

Now create an executable entry point for the zoo package:

packages/zoo/src/index.ts
import { zoo } from './lib/zoo.js';
console.log(zoo());

To build your packages, run:

Terminal window
npx nx build animal

You can also use npx nx run animal:build as an alternative syntax. The <project>:<task> format works for any task in any project, which is useful when task names overlap with Nx commands.

This creates a compiled version of your package in the dist/packages/animal folder. Since the zoo package depends on animal, building zoo will automatically build animal first:

Terminal window
npx nx build zoo

You'll see both packages are built, with outputs in their respective dist folders. This is how you would prepare packages for use internally or for publishing to a package registry like NPM.

You can also run the zoo package to see it in action:

Terminal window
node packages/zoo/dist/index.js

By default Nx simply runs your package.json scripts. However, you can also adopt Nx technology plugins that help abstract away some of the lower-level config and have Nx manage that. One such thing is to automatically identify tasks that can be run for your project from tooling configuration files such as package.json scripts and TypeScript configuration.

In nx.json there's already the @nx/js plugin registered which automatically identifies typecheck and build targets.

nx.json
{
...
"plugins": [
{
"plugin": "@nx/js/typescript",
"options": {
"typecheck": {
"targetName": "typecheck"
},
"build": {
"targetName": "build",
"configName": "tsconfig.lib.json",
"buildDepsName": "build-deps",
"watchDepsName": "watch-deps"
}
}
}
]
}

To view the tasks that Nx has detected, look in the Nx Console project detail view or run:

Terminal window
npx nx show project animal
Project Details View (Simplified)

@org/animal

Root: packages/animal

Type:library

Targets

  • build

    tsc --build tsconfig.lib.json

    Cacheable
  • typecheck

    tsc --build --emitDeclarationOnly

    Cacheable

The @nx/js plugin automatically configures both the build and typecheck tasks based on your TypeScript configuration. Notice also how the outputs are set to {projectRoot}/dist - this is where your compiled TypeScript files will be placed, and it defined by the outDir option in packages/animal/tsconfig.lib.json.

When you develop packages, creating shared utilities that multiple packages can use is a common pattern. This approach offers several benefits:

  • better separation of concerns
  • better reusability
  • more explicit APIs between different parts of your system
  • better scalability in CI by enabling independent test/lint/build commands for each package
  • most importantly: better caching because changes to one package don't invalidate the cache for unrelated packages

Let's create a shared utilities library that both our existing packages can use:

Terminal window
npx nx g @nx/js:library packages/util --bundler=tsc --unitTestRunner=vitest --linter=none

Now we have:

  • Directorymy-nx-repo/
    • Directorypackages/
      • Directoryanimal/
      • Directoryutil/
      • Directoryzoo/
    • ...

Let's add a utility function that our packages can share:

packages/util/src/lib/util.ts
export function util(): string {
return 'util';
}
export function formatMessage(prefix: string, message: string): string {
return `[${prefix}] ${message}`;
}
export function getRandomItem<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)];
}

This allows us to easily import them into other packages. Let's update our animals package to use the shared utility:

packages/animals/src/lib/animals.ts
import { getRandomItem } from '@org/util';
export function animal(): string {
return 'animal';
}
export interface Animal {
name: string;
sound: string;
}
const animals: Animal[] = [
{ name: 'cow', sound: 'moo' },
{ name: 'dog', sound: 'woof' },
{ name: 'pig', sound: 'oink' },
];
export function getRandomAnimal(): Animal {
return getRandomItem(animals);
}

And update the zoo package to use the formatting utility:

packages/zoo/src/lib/zoo.ts
import { getRandomAnimal } from '@org/animal';
import { formatMessage } from '@org/util';
export function zoo(): string {
const result = getRandomAnimal();
const message = `${result.name} says ${result.sound}!`;
return formatMessage('ZOO', message);
}

Update the dependencies in each package's package.json:

packages/animal/package.json
{
"dependencies": {
"@org/util": "*"
}
}
packages/zoo/package.json
{
"dependencies": {
"@org/animal": "*",
"@org/util": "*"
}
}

Link the packages:

Terminal window
npm install

Now when you run npx nx build zoo, Nx will automatically build all the dependencies in the correct order: first util, then animal, and finally zoo.

Run the zoo package to see the updated output format:

Terminal window
node packages/zoo/dist/index.js

Nx automatically detects the dependencies between the various parts of your workspace and builds a project graph. This graph is used by Nx to perform various optimizations such as determining the correct order of execution when running tasks like npx nx build, enabling intelligent caching, and more. Interestingly, you can also visualize it.

Just run:

Terminal window
npx nx graph

You should be able to see something similar to the following in your browser.

Loading...

Let's create a git branch with our new packages so we can open a pull request later:

Terminal window
git checkout -b add-zoo-packages
git add .
git commit -m 'add animal and zoo packages'

Building and testing - running multiple tasks

Section titled “Building and testing - running multiple tasks”

Our packages come with preconfigured building and testing . Let's intentionally introduce a typo in our test to demonstrate the self-healing CI feature later.

You can run tests for individual packages:

Terminal window
npx nx build zoo

Or run multiple tasks in parallel across all packages:

Terminal window
npx nx run-many -t build test

This is exactly what is configured in .github/workflows/ci.yml for the CI pipeline. The run-many command allows you to run multiple tasks across multiple projects in parallel, which is particularly useful in a monorepo setup.

There is a test failure for the zoo package due to the updated message. Don't worry about it for now, we'll fix it in a moment with the help of Nx Cloud's self-healing feature.

One thing to highlight is that Nx is able to cache the tasks you run.

Note that all of these targets are automatically cached by Nx. If you re-run a single one or all of them again, you'll see that the task completes immediately. In addition, there will be a note that a matching cache result was found and therefore the task was not run again.

npx nx run-many -t built test
✔ nx run @org/util:build
✔ nx run @org/util:test
✔ nx run @org/animal:test
✔ nx run @org/animal:build
✖ nx run @org/zoo:test
✔ nx run @org/zoo:build
——————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
NX Ran targets test, build for 3 projects (800ms)
✔ 5/6 succeeded [5 read from cache]
✖ 1/6 targets failed, including the following:
- nx run @org/zoo:test

Not all tasks might be cacheable though. You can configure the cache settings in the targetDefaults property of the nx.json file. You can also learn more about how caching works.

The next section deals with publishing packages to a registry like NPM, but if you are not interested in publishing your packages, you can skip to the end.

If you decide to publish your packages to NPM, Nx can help you manage the release process. Release management involves updating the version of your packages, populating a changelog, and publishing the new version to the NPM registry.

First you'll need to define which projects Nx should manage releases for by setting the release.projects property in nx.json:

nx.json
{
...
"release": {
"projects": ["packages/*"]
}
}

You'll also need to ensure that each package's package.json file sets "private": false so that Nx can publish them. If you have any packages that you do not want to publish, make sure to set "private": true in their package.json.

Now you're ready to use the nx release command to publish your packages. The first time you run nx release, you need to add the --first-release flag so that Nx doesn't try to find the previous version to compare against. It's also recommended to use the --dry-run flag until you're sure about the results of the nx release command, then you can run it a final time without the --dry-run flag.

To preview your first release, run:

Terminal window
npx nx release --first-release --dry-run

The command will ask you a series of questions and then show you what the results would be. Once you are happy with the results, run it again without the --dry-run flag:

Terminal window
npx nx release --first-release

After this first release, you can remove the --first-release flag and just run nx release --dry-run. There is also a dedicated feature page that goes into more detail about how to use the nx release command.

Here are some things you can dive into next:

Also, make sure you