Sharing Code in TypeScript and Project References

tl;dr

  • Strategies for sharing code and how they integrate within the TypeScript ecosystem.
  • Project References enable code sharing across multiple TypeScript projects within a single codebase (monorepo), offering modularization, dependency management, and incremental builds.
  • The simple use of Project References is straightforward but can result in cumbersome import paths like ../../../../../libs/dist/data-access/loader/common.js, which expose the project’s internal folder structure.
  • This post explores solutions to the import path issue, balancing complexity and elegance:
    • TypeScript paths aliasing adds complexity by requiring extra tooling for runtime import resolution.
    • Bundlers, while somewhat cumbersome, largely resolve these issues and provide production deployment tools.
    • npm workspaces mimic external npm dependencies using symlinks in node_modules, addressing import paths without aliasing or extra tooling and enabling package export management for better modularization.
  • In conclusion, TypeScript Project References combined with npm workspaces provide an optimal solution for multi-project code sharing within a single codebase in TypeScript.

First, let’s define four code-sharing strategies:

  1. Same project[1] – Shared or common code resides in the same project as the rest of the code, fully visible to all project code and sharing the same dependencies. Typically, this includes most of the team’s project code. The only distinction of “shared” code is soft team knowledge and folder structure. This is trivial to implement in TypeScript, as in many other languages.
  2. Framework code – Executable code provided by the environment, OS, or platform runtime, such as filesystem, network, and other I/O access. In TypeScript and other languages, this is exposed through core libraries implemented by the runtime (e.g., CLRJRE, or Node.js).
  3. Third-party dependency – Code included in a project as a versioned module, typically written by another team and used “as-is” without modification. This code generally adds widely useful functionality across multiple projects, with package managers like NuGetMaven, and npm supporting this practice.
  4. Product/Team/Company shared code – Code written and maintained by the product team, unversioned, modifiable, and evolving alongside the product. This code may add specific functionality for the product/team/company, such as logging, error handling, security, or sharing code across different projects (e.g., sharing data entities between backend, API server, microservices, and frontend).

In TypeScript, Project References enable scenario #4 by allowing TypeScript projects (i.e., tsconfig.json files) within a single codebase to reference each other, with each project maintaining its own settings, dependencies, and build information. This supports modularization, dependency management, and incremental builds. However, I found it challenging to use due to transpilation and Node.js runtime dependency resolution. This was likely influenced by outdated documentation[2], overly simplified tutorials[3], the prevalence of CommonJS examples, and some personal preconceptions.

To clarify the issue and solutions, I methodically explored options from CommonJS to ESM, TSX, bundlers, and npm workspaces. Starting with the basics and gradually advancing solidified my understanding, and I hope it will do the same for the reader.

Defining the Problem

Given “main” and “libs” as TS sibling projects, how do we import common.ts from “libs” in main.ts in “main”:

.
├── libs
│   ├── src
│   │   ├── common.ts
│   ├── package.json
│   └── tsconfig.json
├── main
│   ├── src
│   │   ├── main.ts
│   ├── package.json
│   └── tsconfig.json

// common.ts
export function logMessage() {
  console.log('Logging message in libs');
}

// main.ts
import { logMessage } from '?????????';
logMessage();

1. CommonJS and Folder Dependency

See example.

The simplest setup, with all basics needed for TS project references. However, the import relative path exposes the project structure, and with deeper file trees, this results in cumbersome imports like ../../../../../../libs.
Note: tsc needs a --build flag to handle project references during the build, enabling it to track and build referenced packages if required.

For completeness, here are the package.jsontsconfig.json, and main.ts files:

// common library package.json
{
  "name": "libs",
  "main": "dist/common.js" // for folder dependency
}

// common library tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true // marks the package as project reference for tsc
  }
}

// main app tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    {
      "path": "../libs" // reference for tsc on the common library
    }
  ]
}

// main.ts
import { logMessage } from '../../libs';
logMessage();

2. CommonJS w/o Folder Dependency

See example.

If we don’t want to use the main field in favor of a full path, we introduce some import awkwardness. The import must use the relative path to the outDir location rather than the TS source files. Initially, this was confusing, but it’s how it works because: (1) when Node.js executes, it only sees the relative location of the output files, and (2) the TS transpilation process doesn’t alter import statements. Both the TS compiler and VSCode understand this and function correctly.

For completeness, here are the package.json and main.ts files while the tsconfig.jsons remain unchanged from the previous option:

// common library package.json
{
  "name": "libs" // no "main" field
}

// main.ts
// import { logMessage } from '../../libs/src/common'; // Cannot find module error
import { logMessage } from '../../libs/dist/common'; // import path points to outDir of the library
logMessage();

3. ESM

See example.

ESM (ECMAScript Modules) is the standard, future module system and offers significant advantages[4]. Therefore, I wanted to focus on making it work correctly. It mostly requires small changes, but it’s essential to configure everything accurately to ensure all packages use the correct module type and module resolution method. Similar to CJS, the relative paths to the library’s output folder are awkward and can quickly get out of hand: ../../../../../libs/dist/data-access/loader/common.js. They also require updates if the project structure changes. Still, this setup is simple, needs no additional tooling, and works effectively.

For completeness, here are the package.jsontsconfig.json, and main.ts files:

// common library package.json
{
  "name": "libs",
  "type": "module" // define the package as ESM
}

// common library tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16", // latest stable module spec
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true // marks the package as project reference for tsc
  }
}

// main app tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16", // latest stable module spec
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    {
      "path": "../libs" // reference for tsc on the common library
    }
  ]
}

// main.ts
import { logMessage } from '../../libs/dist/common.js';
logMessage();

4. ESM with Paths Aliases

To address the import path issue, I looked into TypeScript paths to create a mapping that shortens the import path and hides the project structure. The problem is that the TS compiler doesn’t modify emitted JavaScript code[5], so Node.js requires additional runtime support to resolve modules.

There are a few workaround solutions, such as loaders and executers, which I’ll explain here. Bundlers are discussed in the next section. Unfortunately, Node.js Subpath imports don’t work here because they require all source files to be under the same package (example).

Custom loader (see example)

This approach handles path aliasing at runtime. It corrects the relative path using configuration in package.json (like esm-module-alias), parsing tsconfig.json (like ts-paths-esm-loader), or a custom loader to allow custom handling.

To use a custom loader, specify it to Node.js with: node --loader=ts-paths-esm-loader --no-warnings. The warning adds to my unease about this solution.

Executers (see example)

Another approach is to avoid Node.js’s lack of support for TS paths by using executors that transpile TS code at runtime, thus managing module resolution within TypeScript. There are several options available. I, like others, found tsx the fastest and simplest. Running tsx is as easy as tsx src/main.ts, with no compilation required, and it points directly to the TS code.

However, running transpilation at runtime in production seems excessive to me (though not everyone agrees). It’s excellent for development since it’s very fast, but for production, we’re back to square one.

Here are changes from the previous option to use paths:

// main app tsconfig.json
{
  "compilerOptions": {
    ...
    "baseUrl": "./src", // needed to define the root of relative paths
    "paths": {
      "@libs/*": ["../../libs/src/*"] // path mappings with wildcards
    }
  }
}

// main.ts
import { logMessage } from '@libs/common.js'; // nice and clean import
logMessage();

5. ESM with Bundler

See example.

Another option to resolve paths alias issues in Node.js is to bypass it entirely by bundling the TS files into a single bundle.js file. At runtime, all code will be in the single bundle, eliminating the need for import resolutions. With bundler support, the TS paths configuration works without extra complexity. I found Parcel to be the easiest, zero-config bundler with excellent TypeScript support.

Using a bundler is a solid solution to the paths aliasing problem. Moreover, bundlers offer additional benefits and are often needed for production anyway. However, after testing multiple tools to handle paths and observing how infrequently they worked out-of-the-box, I suspect bundling is only a partial solution, as similar issues might still arise with other tools like testing and linting.

To use Parcel, install it and run it on the source entry point TS file:

npm install -D parcel
parcel build src/main.ts --dist-dir bundle

6. ESM with npm workspaces

See example.

npm workspaces is a set of features in the npm CLI that supports managing multiple packages within a single, top-level root package. This setup automatically links packages defined in the root package.json when running npm install. Practically, workspaces place the node_modules folder at the root, creating symlinks to all workspace packages.

The resulting folder tree has node_modules at the root, with symlinks to the workspace packages:

.
├── node_modules
│   ├── libs -> ../libs  (symlink)
│   └── main -> ../main  (symlink)
├── package.json
├── package-lock.json
├── libs
│   ├── src
│   ├── package.json
│   └── tsconfig.json
├── main
│   ├── src
│   ├── package.json
│   └── tsconfig.json

This setup allows dependent packages to be imported as regular “node modules,” without the need for path aliasing or additional tools. Additionally, the exports field in package.json can control code available for import, preventing access to “package-internal” code.

Note: TS project references are still useful for instructing the TS compiler to build referenced packages in the correct order and for build performance with declaration files and incremental builds.

For completeness, here are the package.jsontsconfig.json, and main.ts files:

// common library package.json
{
  "name": "libs",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "./*": "./dist/*" // package export controls
  }
}

// common library tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true
  }
}

// main app package.json
{
  "name": "main",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "libs": "1.0.0" // dependency on the common library
  }
}

// main app tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    {
      "path": "../libs" // reference for tsc on the common library
    }
  ]
}

// root workspace package.json
{
  "private": true, // must be private
  "name": "root-workspace",
  "workspaces": ["libs", "main"] // the packages to combine in the workspace
}

// main.ts
import { logMessage } from 'libs/common.js'; // clean import, no aliasing required
logMessage();

Conclusion

The ecosystem is vast and complicated. There are more options I didn’t cover and scenarios I haven’t considered. However, I believe using npm workspaces is the best[6] starting point, as it’s the cleanest solution for sharing code with minimal overhead. Optionally, adding a bundler for production deployment and an executor for fast development flows is also viable.

References

Some valuable resources I read during this deep dive:


  1. Here, “library” can be as defined in C++, .NET/C#, Java, or a package in TypeScript. 
  2. Looking at you, TypeScript Project References, with “prepend” being deprecated for over a year. 
  3. Simple examples can be misleading, as seen in posts like this with CJS and folder dependency. 
  4. Example: Migrating a 60k LOC TypeScript (NodeJS) repo to ESM 
  5. This usage isn’t the intended use case, as discussed here
  6. Or any other monorepo tool that provides similar functionality. 

2 comments on “Sharing Code in TypeScript and Project References

  1. taernsietr's avatar taernsietr says:

    Although I am not a very senior developer, I think it speaks volumes that despite this excellent writeup (in addition to many others I’ve found) and reading through dozens of documentation pages, I still can’t make my project run adequately.My case is a monorepo with separate frontend, backend and shared code packages, which I try to import using path aliases. After days of research, I managed to get the built backend project working using tsc-alias and direct imports for shared code, but no luck for running in dev with ts-node. Smooth language, but a very frustrating tooling.

  2. […] The Art of Dev. (2024). Sharing Code in TypeScript and Project References. Retrieved from https://theartofdev.com/2024/11/07/sharing-code-in-typescript-and-project-references/ […]

Leave a reply to taernsietr Cancel reply