How to Organize Your TypeScript Project with Workspaces

Many programming language ecosystems provide a means of subdividing a project into multiple modules. For example, a large .NET solution or XCode workspace is comprised of multiple projects.

In the JavaScript wild west, there is no primary governing entity to drive a standard approach to this, but major package managers have developed their own similar “workspaces” features. Layer TypeScript on top and things can get a little complicated. Below, I’ll walk through how to combine Yarn Workspaces with TypeScript Project References to assemble something resembling other languages’ project structures.

Project Structure

Let’s contrive a really simple example with three modules:

  1. There’s some logic in a shared library.
  2. The library has a test suite.
  3. A Command Line Interface (CLI) tool consumes the library.

In Yarn parlance, these modules are called workspaces. The root-level project contains a package.json file, as does each workspace:

.
├── package.json
├── hello-lib
│   └── package.json
├── hello-lib-test
│   └── package.json
└── hello-cli
    └── package.json

The top-level package.json references its children with a “workspaces” field:


{
  "name": "hello-typescript",
  "workspaces": [
    "hello-lib",
    "hello-lib-test",
    "hello-cli"
  ]
}

So now we have a collection of independent projects. Each one has its own list of package dependencies, its own tsconfig, etc. Notably, they’ll all share a single yarn install step (and the .yarn directory it produces).

Next, we’ll add references between them.

Double References

In order for hello-cli to use hello-lib, we’ll need to express that dependency twice: once for Yarn, and again for TypeScript.

For Yarn, we add a specially-formatted package dependency in the consumer’s package.json:


  "devDependencies": {
    "hello-lib": "workspace:*",
  }

This allows us to import code from “hello-lib”, providing the library has been compiled.

Next, we’ll teach TypeScript about the dependency by adding a references section to the tsconfig:


  "references": [
    {
      "path": "../hello-lib"
    }
  ]

This, combined with composite:true in the referenced project, enables recursive builds. That means when you invoke tsc --build, TypeScript will (re)build the dependencies as needed before building the current module.

Benefits

As with the project organization features of other languages, this allows you to impose more architecture than you get when all your code lives in one giant bucket:

  • You can see and reason about the set of workspaces, the relationships between them, and their exposed interfaces.
  • Each workspace has its own list of third-party dependencies, restricting what’s available in any given area of the app. When you’re autocompleting an import, your editor will have a much easier job of searching the possibilities. There’s no chance of inadvertently roping your database client into your frontend bundle.
  • Each workspace has its own TypeScript configuration, so they can have different ideas about the language target, whether JSX is available, etc. Workspaces meant solely for Node.js or the browser can target particular sets of language features, while portable shared code can use a lowest-common-denominator.

Pros and Cons of Organizing Your TypeScript Project with Workspaces

It’s not all sunshine and rainbows: if you want to organize your Node.js project, you’ll be responsible for more of the plumbing than you might in other ecosystems. And by veering off the beaten path, you’re more likely to encounter compatibility issues across the ecosystem. But in my opinion it’s worth it! I’d encourage you to split your project into multiple workspaces, whether that’s with NPM, Yarn, PNPM, or Lerna.

This post’s working example project can be found on GitHub.