Sharing TypeScript with Nx and Turborepo: An Introduction to Monorepos

Part 1: An introduction to sharing TypeScript code and configuration across a monorepo using pnpm and either Nx or Turborepo

Joseph T. Lapp
JavaScript in Plain English

--

Sharing TypeScript — Part 1: Intro to Monorepos, by Joe Lapp

This series explains how to use Nx and Turborepo monorepos to share code and configuration across multiple TypeScript projects. This first article describes the problem of code sharing, introduces monorepos for solving this problem, and explains the series’ choice of Nx, Turborepo, and pnpm.

Large projects often end up developing multiple applications that need to share configuration and code. It can be frustrating to have to interrupt a project for a week to select and implement a monorepo solution that allows you to cleanly start the next application. I had dealt with this before, but this time around I decided to invest time understanding my options and fully document what I learned. This series is the result.

We’ll be using pnpm with both Nx and Turborepo. In this series, I explain what a monorepo is, what these tools are, why I chose these tools, how to use these tools, and how to structure a monorepo that lets you switch between Nx and Turborepo with little change in configuration. We’ll cover usage with Next.js, SvelteKit, VS Code, and various popular dev tools.

Table of Contents

The Problem of Code Sharing
The Monorepo Solution
Anatomy of a Monorepo
The Role of a Monorepo Manager
Overview of Nx and Turborepo
My Experience with Nx and Turborepo
Why Use pnpm
What Next?

The Problem of Sharing Code

Modern web projects often end up having multiple applications that need to share configuration and code. Separate frontend and backend applications might share API types and data validation code. The frontend might be divided into separate platform and documentation sites that need to share UI components. And the backend and admin tool might share model classes. They might also variously share development configuration.

When developing in TypeScript, each application has its own TypeScript configuration, and the configurations might not be compatible. If the project is simple enough, we might be able to get away with a default tsconfig.json in the project root and alternative configurations in subdirectories. When that doesn’t work, we find ourselves trying to share libraries as individually published packages, requiring us to republish and reinstall each package in each application after every little change.

These solutions are not sustainable. We need to be able to maintain multiple applications and multiple libraries within the same repository, while still configuring each independently. Monorepos solve the problem.

The Monorepo Solution

A monorepo is a single code repository containing a collection of related applications and libraries. Each application and library — each project — resides in its own subdirectory with its own configuration. Projects declare dependencies on other projects of the same monorepo as if the projects were external NPM packages, doing so with a special dependency syntax.

We structure monorepos with a dependency manager such as npm, pnpm, or yarn. Each of these tools organizes the projects of a monorepo into directories called “workspaces.” Each workspace contains a single project along with a package.json file that gives the project a name. Projects may declare dependencies on other projects by referencing these names.

Here is a dependency graph for a hypothetical monorepo containing a few applications, libraries, and configurations, all of which are “projects”:

The arrows indicate which projects depend on which other projects. You’ll notice that projects can depend on configurations in configuration workspaces. These workspaces provide shared configuration. Putting shared configuration in workspaces adds configurations to the dependency tree. This enables tools to automatically rebuild the projects that depend on a changed configuration without also having to rebuild other projects.

We can organize the workspaces of a monorepo any way we want. We can put workspaces in the root of the monorepo, or we can create umbrella directories that contain groups of related workspaces. Here are some example approaches to organizing a monorepo:

Monorepos prevent us from having to create complex configurations to allow code to be shared across projects within a single repo. They also prevent us from dividing our projects into multiple separately maintained NPM packages in order to avoid these complications. Even if we anticipate a project becoming its own NPM utility package, monorepos allow us to easily develop the package in close association with the application that’s driving its requirements before budding it off for separate maintenance.

Anatomy of a Monorepo

Let’s look at the parts of a monorepo. We’ve already seen that a “workspace” is a directory that holds an application, library, or shared configuration. The “project” is the contents of a workspace.

Recall that a conventional code repository has a single package.json file for building the repository and for making it available to others as an NPM package. A monorepo also has a package.json for the monorepo as a whole, but so does each project in the monorepo. (Nx need not have a per-project package.json, but we won’t be taking that approach.)

Consider the following schematic of a pnpm monorepo:

The monorepo root contains the following files:

  • Root package.json — Describes the monorepo as a whole, installs CLI tools, and provides scripts for managing the monorepo.
  • pnpm-workspace.yaml — Tells the pnpm package manager which directories are the workspaces, possibly using wildcards.
  • myrepo.code-workspace — Tells Visual Studio Code (VS Code) which directories to present as workspace in the sidebar. (Replace the myrepo portion of the filename with the name of your repo.)
  • nx.json — Configures Nx for the monorepo, establishing the available tasks and how to execute them across the monorepo.
  • turbo.json — Configures Turborepo for the monorepo, establishing the available tasks and how to execute them across the monorepo.

Each project contains the following files:

  • Project package.json— Describes the project, enumerates it dependencies, and provides scripts for managing the project. Used to build the project into a package, as in a conventional repository.
  • Source code for the project, if it’s not shared configuration. As usual, you can put the source in any directory you want (e.g. src/).
  • Configuration files that can defer to a shared configuration, provide project-specific configuration, or reference shared configuration that they selectively override.

As usual, each package.json can define both runtime and development dependencies. However, in a monorepo, the package.json of a project can declare dependencies on other projects of the monorepo. Dependencies on other projects of the monorepo are “internal dependencies”, while dependencies on installed NPM packages are “external dependencies.” We manage these dependencies with package managers like npm, pnpm, and yarn, but we still need a tool for coordinating all of the projects.

The Role of a Monorepo Manager

We use a monorepo manager to coordinate the projects of a monorepo. There are many available monorepo managers, including Nx, Turborepo, Lerna, Rush, Bit, and Bazel. I couldn’t explore them all and so chose to focus on two of the most popular tools, Nx and Turborepo.

Nx and Turborepo primarily improve monorepo management as follows:

  • They deduce the dependency relationships among projects and run tasks in reverse dependency order so that dependent projects are processed first. Sometimes projects have to be built or tested in a particular order, such to make web bundles, generated code or data, or make TypeScript declaration files available to other projects. We would rather not have to maintain scripts that hardcode task execution order.
  • They speed up task execution by running tasks in parallel when their dependency relationships allow them to run in parallel. This is especially helpful when building large code bases.
  • They further speed up builds by caching build artifacts and only rebuilding artifacts whose associated code has changed. Both Nx and Turborepo can cache locally or in the cloud. Under cloud-based caching, when any team member builds new artifacts, no other team member needs to rebuild them, saving a lot of time on large projects.
  • They speed up automatic TypeScript type-checking within VS Code. By partitioning a large project into multiple small projects and building them in dependency order, each small project can build and expose .d.ts files, preventing VS Code from having to peer deeply into the source for types. Type checking can slow large projects so much that devs often move on before VS Code has highlighted problems.

There’s a nice side-benefit to having a tool that builds projects in the correct order: we can flatten the source tree. When we have to remember the build order, we tend to nest multiple libraries together within projects to minimize the total number of packages. But this nesting makes it harder to find libraries and harder to later make the libraries more generally available. Monorepo managers allow us to be comfortable with making these libraries separate packages that we can individually import.

Overview of Nx and Turborepo

Nx and Turborepo are both equally capable of managing monorepos whose projects manage their own dependencies, as described in this series. They both also have additional features we will not cover, except for a brief explanation of Nx’s ability to centrally manage dependencies.

Nx was first released in 2017 and has focused on addressing the monorepo requirements of large teams. It is more mature and feature-rich than Turborepo and provides the ability to centrally manage dependencies, doing away with the per-project package.json. When taking this approach, it is much easier to manage dependencies across the monorepo and ensure that different projects use the same versions of external dependencies. Nx also provides framework-specific code generators to assist with getting new projects started within a monorepo.

Turborepo was first released in 2021, four years after Nx. It has focused on keeping things simple, and it has succeeded admirably at that. Turborepo only provides management for dependencies that are separately maintained for each project; Turborepo does not see dependencies that are centrally managed. The Turborepo documentation is clear and easy to follow, and the template monorepos I’ve tried have all worked without modification after installation.

Unlike Turborepo, Nx has the ability to centrally manage dependencies, making it much easier to manage dependencies across projects. Rather than install a package once for each project that requires it, you can install it only once for the whole monorepo. This makes it easy to ensure that different projects use the same versions of external dependencies. But this ability comes at a cost: we must now rely on Nx to read source code to deduce dependencies, to “wrap” everyday developer tools in Nx tooling to get the tools to work with centralized dependencies, and to generate the per-project configurations that work with central dependencies. This may be the source of the complexity that people complain about with Nx.

My Experience with Nx and Turborepo

When I began my journey to understand monorepos in February 2023, I started with Nx because it seemed to be the most popular solution. I was able to get an Express application to build, but I failed in my efforts to add a Node.js CLI application to the monorepo. In fact, the generated code that Nx produced would not compile. I reported one error, which the Nx team promptly fixed, but I continued to find errors, and fixing the generated code myself still did not leave me with a functioning application. I assumed that my understanding was still lacking, but rather than continue pouring through the Nx documentation, I decided to try Turborepo.

When I switched to Turborepo, progress was continuous and I managed to get a monorepo working with Next.js and a Node.js CLI. The effort was not entirely smooth though, probably because the Turborepo docs did not clearly explain the requirement that each project must manage all of its dependencies — in my confused state coming from Nx, I needed this made explicit. However, the Turborepo documentation was still more helpful than Nx’s, as Nx took a scattershot approach to educating me, requiring me to bounce from page to page to piece together understanding.

I thought I would stick with Turborepo, but when I revisited Nx to be sure I could say something helpful for this series, I found that Nx ran problem-free on my Turborepo monorepo, with very little reconfiguration. I found myself preferring Nx for its cleaner, faster output. Under Turborepo, I had been struggling to locate information about build errors and test errors in the output, despite configuring Turborepo to only output errors. Nx makes the relevant information plainly visible. Turborepo tasks also seem to have a minimum start-up time, while Nx can be instantaneous.

Even so, not all is perfect with Nx, as it sometimes gives me an error for failing to find node_modules/nx/package.json on the first run after installing dependencies. The error goes away simply by re-running the command, but it can be problematic when the command is in the middle of a script.

In the end, I learned that the monorepo structure that Turborepo requires is the structure that’s easiest to use with Nx. Under this structure, each project manages its own dependencies in its own package.json, as previously described. On my first attempt with Nx, I had been confusing the tooling for centrally managing dependencies with that of separately managing dependencies. I now find myself preferring to model new monorepos on Turborepo’s example templates even when using Nx.

I don’t know whether it is better for large teams to work with centralized dependencies, but if you want to centralize dependencies, you’ll need harder-won expertise with Nx than I provide in this series. In this series, we’ll focus on the monorepo structure that works well with both Nx and Turborepo. We’ll also examine the use of the Syncpack tool to keep dependency versions consistent across a monorepo’s projects.

Why Use pnpm

It was much easier to choose a package manager, as among npm, yarn, and pnpm, only pnpm is proven to work efficiently with monorepos.

Yarn was originally created to address some of the shortcomings of npm, but npm largely caught up with yarn version 1. Yarn version 2 provides a new installation technique called “Plug’n’Play,” but its issues are still being worked out and it has not been widely adopted, so we won’t be considering yarn version 2. The community refers to yarn version 1 as “classic yarn.”

npm and classic yarn operate similarly. They store the dependencies of each project separately, placing them in per-project node_modules directories. If multiple projects of the monorepo depend on the same version of the same external package, the external package still gets installed multiple times, once in each project's node_modules directory.

In contrast, pnpm stores each version of each package only once within the root node_modules and uses symbolic links to reference them from the individual projects. This saves both disk space and computation time. pnpm's approach allows us to be fearlessly abundant with the creation of library workspaces. We don't have to worry about duplicate dependencies excessively consuming disk space, and having only one copy of each dependency saves us installation time too. Moreover, given that Nx and Turborepo cache build artifacts on a per-workspace basis, the more libraries we have, the less source that needs to be rebuilt on code changes, and the faster each build of the monorepo is.

pnpm may also be a good future-proofing choice because there are open discussions and an RFC proposing to have pnpm address the problem of inconsistencies in the versions of external packages.

The Turborepo docs also recommend pnpm. For all of these reasons, this series focuses on the use of pnpm with monorepos. Fortunately, the syntax for pnpm commands is virtually identical to that of yarn, which itself is similar to npm, making pnpm easy to learn.

What Next?

That concludes our introduction. We’ve covered the problem of sharing code and configuration, introduced monorepos, and discussed pnpm, Nx, and Turborepo. We saw that we a monorepo can be compatible with both Nx and Turborepo by using project workspaces that each have its own package.json. pnpm improves the performance of dependency management and greatly reduces the disk space required, while Nx and Turborepo provide task coordination and build caching services, greatly speeding up the time required to build and test monorepo projects.

Now we’re ready to dive into the details of Nx, Turborepo, and pnpm. In Part 2: Creating a Monorepo, we’ll create and examine a monorepo, preparing us to be able to configure monorepos to suit specific needs.

More content at PlainEnglish.io.

Sign up for our free weekly newsletter. Follow us on Twitter, LinkedIn, YouTube, and Discord.

--

--

Sr. Software Engineer. Full stack TS/Node/Svelte and performant Java. Patents, specs, UML, tutorials. Learning Rust and DDD. JoeLapp.com