Blog
Juri Strumpflohner
January 28, 2025

Managing TypeScript Packages in Monorepos

Managing TypeScript Packages in Monorepos
TypeScript Project References Series

This article is part of the TypeScript Project References series:

Managing TypeScript packages in a monorepo presents unique challenges. As your monorepo grows, so does the complexity of structuring and resolving dependencies between packages. From using simple relative imports to taking advantage of TypeScript path aliases, project references, and your package manager's workspaces feature, developers have a variety of strategies at their disposal. But which approach is the best fit for you?

What Does it Mean to Manage and Share TypeScript Code in a Monorepo?

When you work in a monorepo, the goal is to split logic into separate packages. Why? To create smaller, self-contained, and maintainable units. This approach not only enhances reusability but also helps scale: whether that's scaling teams or optimizing CI pipelines.

As you split logic into packages, you'll inevitably need to somehow connect them together. At the code level, this is typically expressed through an import statement on the consumer side—whether that's an application using a package or one package depending on another.

1import { something } from '@tsmono/mypackage'; 2

To have this work we need to be able to resolve the @tsmono/mypackage import to the actual file path. This needs to happen during:

  • Build: This includes type checking and compilation/transpilation.
  • Runtime: This is when the application runs in the browser/on the server.

In this article we'll mostly focus on the building part, in particular type checking. In real world applications you'll most often have some sort of bundler in the pipeline where tsc is used for type checking and the actual compilation part is being taken care of by the bundler (e.g. esbuild, Rspack, or Vite).

There are several approaches to connect TypeScript packages in a monorepo, each with its own trade-offs. Let's explore them in detail.

Using Relative Imports

Example: Stackblitz - Github

The simplest approach to connecting packages is using relative imports. Here's an example of the setup:

1└─ . 2 ├─ apps 3 │ └─ myapp 4 │ ├─ src 5 │ │ └─ index.ts 6 │ └─ tsconfig.json 7 ├─ packages 8 │ └─ lib-a 9 │ ├─ src 10 │ │ └─ index.ts 11 │ └─ tsconfig.json 12 └─ tsconfig.base.json 13 14

Here's the content of the main TypeScript configuration files:

Starting at the root, the tsconfig.base.json looks as follows. It is meant to set some of the base compilation properties which can then be adjusted further by individual projects in your workspace.

tsconfig.base.json
1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "NodeNext", 5 "strict": true, 6 "moduleResolution": "NodeNext", 7 "baseUrl": ".", 8 "rootDir": "." 9 } 10} 11

With this setup, the main application can consume the library using a relative import:

apps/myapp/src/index.ts
1import { greet } from '../../../packages/lib-a/src/index'; 2 3console.log(greet('World')); 4

We can have some scripts in our main package.json at the workspace root to run our TypeScript code directly, compile it to JavaScript and for performing type checking.

package.json
1{ 2 "name": "ts-monorepo-linking", 3 "private": true, 4 "devDependencies": { 5 "typescript": "^5.3.3", 6 "tsx": "^4.1.0" 7 }, 8 "scripts": { 9 "dev": "tsx apps/myapp/src/index.ts", 10 "build": "tsc -p apps/myapp/tsconfig.json", 11 "typecheck": "tsc -p apps/myapp/tsconfig.json --noEmit" 12 } 13} 14

Observations: Relative Imports

Dependency resolution:
Relative imports are the simplest way to link packages, but they are fragile. Restructuring your codebase will require updating import paths, which can become unmanageable in larger workspaces.

Modularity:
This setup enables modularization at the organizational level. Apps and libraries are placed in separate folders, making the workspace easier to navigate. However, from TypeScript's perspective, the entire workspace is treated as a single, unified project. This means there are no strict boundaries between packages at the type-checking level.

Performance:
Treating the entire workspace as a single TypeScript project generally works for small setups but can become problematic as the workspace grows. Type checking and compilation span the entire repo, which may lead to higher memory usage, slower builds, and sluggish editor responsiveness in larger workspaces.

Fixing Relative Imports with TypeScript Path Aliases

Example: Stackblitz - Github

Relative imports are functional but difficult to maintain in larger workspaces. A simple improvement is to use TypeScript path aliases. These allow you to create custom paths for imports, making the codebase easier to navigate and refactor.

In the tsconfig.base.json you can define a path alias for the lib-a package:

tsconfig.base.json
1{ 2 "compilerOptions": { 3 ... 4 "paths": { 5 "@ts-monorepo-linking/lib-a": ["packages/lib-a/src/index.ts"] 6 } 7 } 8} 9

Note you can use whatever name you want for the alias. Choosing @ts-monorepo-linking/lib-a makes it look like an actual import of an external package, thus closer to a structure we want to achieve.

With this setup, you can simplify the import in your application:

apps/myapp/src/index.ts
1import { greet } from '@ts-monorepo-linking/lib-a'; 2 3console.log(greet('World')); 4

Observations: TypeScript Path Aliases

Dependency resolution:
Path aliases eliminate the need for relative imports, resulting in a cleaner and more maintainable structure. If the underlying paths change, you only need to update the alias in tsconfig.base.json.

As a side note: While this article focuses on the build and type-checking phase, it's worth noting that running the transpiled TypeScript code directly wouldn't work out of the box. This is because TypeScript path aliases are purely a compile-time construct—they don't exist in the output JavaScript. To run the application, you'd need a runtime plugin or bundler (like Webpack, esbuild, or Vite) that can resolve these aliases to actual file paths.

Modularity:
This approach doesn't change the modularity from the relative imports setup. TypeScript still treats the entire workspace as one large project, without enforcing strict boundaries between packages.

Performance:
Path aliases don't improve performance compared to relative imports. The entire workspace is still treated as a single TypeScript project, so type checking and compilation remain unchanged. This approach focuses on maintainability and readability rather than optimizing performance.

Improving Performance with Project References

Example: Stackblitz - Github

TypeScript project references let you break a large TypeScript project into smaller, manageable units. This approach aligns with monorepo structures, allowing each package to act as its own TypeScript program while maintaining relationships between them.

To use project references:

  • Add the references property in tsconfig.json files to point to dependent projects.
  • Enable composite: true in compilerOptions (this also enables incremental and declaration by default).
  • Use tsc --build (tsc -b) for compilation and type checking.

For a more deep-dive on TypeScript project references, make sure to check out our article on "Everything You Need to Know About TypeScript Project References".

Our workspace structure still remains the same with the exception of adding another root-level tsconfig.json:

1ts-monorepo-linking 2 ├─ apps 3 │ └─ myapp 4 │ ├─ src 5 │ │ └─ index.ts 6 │ └─ tsconfig.json 7 ├─ packages 8 │ └─ lib-a 9 │ ├─ src 10 │ │ └─ index.ts 11 │ └─ tsconfig.json 12 ├─ tsconfig.base.json 13 └─ tsconfig.json 14

This new tsconfig.json is the entry point for TypeScript project references, pointing to all individual TypeScript configs of the projects that are part of the monorepo workspace.

tsconfig.json
1{ 2 "files": [], 3 "references": [{ "path": "./packages/lib-a" }, { "path": "./apps/myapp" }] 4} 5

This is distinct from tsconfig.base.json, which is used to share common configurations across the workspace:

tsconfig.base.json
1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "NodeNext", 5 "strict": true, 6 "moduleResolution": "NodeNext", 7 "composite": true, 8 "declaration": true, 9 "declarationMap": true, 10 "sourceMap": true, 11 "paths": { 12 "@ts-monorepo-linking/lib-a": ["packages/lib-a/src/index.ts"] 13 } 14 } 15} 16

Note that in the tsconfig.base.json we removed the rootDir from our TypeScript configuration (compared to the pure TypeScript path aliases setup). The reason is that we no longer treat the entire workspace as a single TypeScript project. Instead, each project's tsconfig.json forms its own TypeScript root and will be processed by TypeScript's project references individually.

The myapp and lib-a configurations look as follows:

apps/myapp/tsconfig.json
1{ 2 "extends": "../../tsconfig.base.json", 3 "compilerOptions": { 4 "outDir": "../../dist/apps/myapp", 5 "rootDir": "src", 6 "tsBuildInfoFile": "../../dist/apps/myapp/tsconfig.tsbuildinfo" 7 }, 8 "references": [{ "path": "../../packages/lib-a" }], 9 "include": ["src/**/*"] 10} 11

Changing the configuration alone isn't enough. If you continue using tsc (or tsc -p), TypeScript will ignore your project references and treat the workspace as a single large project. To fully leverage project references, you must switch to using the --build (-b) flag with tsc. This mode enables TypeScript to process each project individually, respecting dependencies defined in the references property.

package.json
1{ 2 "name": "ts-monorepo-linking", 3 ... 4 "scripts": { 5 "dev": "tsx --tsconfig tsconfig.base.json apps/myapp/src/index.ts", 6 "build": "tsc --build", 7 "clean": "tsc --build --clean", 8 "typecheck": "tsc --build --emitDeclarationOnly" 9 } 10} 11

Note: --noEmit is not compatible with the --build flag. Use --emitDeclarationOnly.

Observations: Project References

Dependency resolution:
Imports remain the same, relying on TypeScript path aliases to resolve dependencies. Project references don't change how TypeScript resolves paths; they focus on modularizing type checking and compilation.

Modularity:
Project references create stronger boundaries by treating each package as an independent TypeScript program. This enforces better isolation and ensures dependencies are type-checked at the package level.

Performance:
This approach introduces incremental builds, where only modified packages are recompiled. TypeScript generates .tsbuildinfo files to track changes, reducing memory usage and speeding up type checking and compilation. This is particularly beneficial for large workspaces or CI pipelines.

From a TypeScript program structure we now don't have a single TypeScript program, but multiple ones.

1ts-monorepo-linking 2 ├─ apps 3 │ └─ myapp 4 │ ├─ src 5 │ └─ tsconfig.json <<< myapp TS program 6 ├─ packages 7 │ └─ lib-a 8 │ ├─ src 9 │ └─ tsconfig.json <<< lib-a TS program 10 ├─ tsconfig.base.json 11 └─ tsconfig.json <<< root-level solution tsconfig 12

The incremental nature of project references allows TypeScript to track changes and skip unnecessary recompilation, resulting in:

  • Faster builds: Only projects affected by changes are recompiled, saving time during development and on CI.
  • Lower memory usage: Processing smaller, isolated projects is more memory efficient, which is particularly helpful in large workspaces or resource-constrained environments like CI pipelines.
  • Improved editor performance: TypeScript's incremental setup ensures quicker type-checking and autocomplete, even in large monorepos.

Combining TypeScript Project References and Package Manager Workspaces

Example: Stackblitz - Github

Modern package managers like NPM, PNPM, Yarn, and Bun have a so-called "workspaces feature" that allows for a more seamless resolution of local packages that allows for a more seamless resolution of local packages.

For most package managers, you can use the workspaces property in the root package.json to define the packages that are part of the monorepo.

package.json
1{ 2 "name": "ts-monorepo-linking", 3 ... 4 "workspaces": [ 5 "apps/*", 6 "packages/*" 7 ] 8} 9 10

This approach eliminates the need for TypeScript path aliases for module resolution.

tsconfig.base.json
1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "module": "NodeNext", 5 "strict": true, 6 "moduleResolution": "NodeNext", 7 "composite": true, 8 "declaration": true, 9 "declarationMap": true, 10 "sourceMap": true, 11 } 12} 13

Instead, the package manager's workspaces feature makes sure to link the packages properly such that they can be resolved correctly at build and runtime. This doesn't have any impact on our TypeScript project references setup which cares about type checking and resolves dependencies via the references property.

Note, setting up package resolution with workspaces is the generally recommended approach. More on that later.

In an NPM/Yarn/PNPM workspace packages tend to be more self-contained. As such it is common to have the output directly in a dist folder within the package itself. We also adjust the baseUrl to be at the package root.

apps/myapp/tsconfig.json
1{ 2 "extends": "../../tsconfig.base.json", 3 "compilerOptions": { 4 "outDir": "dist", 5 "rootDir": "src", 6 "baseUrl": ".", 7 "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" 8 }, 9 "references": [{ "path": "../../packages/lib-a" }], 10 "include": ["src/**/*"] 11} 12

Each package requires to have a package.json that defines the contract for its entry points, types, and dependencies on other packages.

Here's our updated package.json for lib-a:

packages/lib-a/package.json
1{ 2 "name": "@ts-monorepo-linking/lib-a", 3 ... 4 "type": "module", 5 "exports": { 6 ".": { 7 "types": "./src/index.ts", 8 "import": "./src/index.ts", 9 "default": "./src/index.ts" 10 }, 11 "./package.json": "./package.json" 12 }, 13 "main": "./src/index.ts", 14 "types": "./src/index.ts", 15 "module": "./src/index.ts" 16} 17

Note how it directly exports the index.ts file, eliminating the need for pre-compilation. This approach works regardless of whether you use TypeScript project references, as long as the consuming application handles compilation or transpilation. The package manager's workspaces feature makes sure that the packages are properly linked so they can be resolved at runtime (by Node or respective bundler).

Do I need to reference dependent packages in the consuming package's package.json?

For NPM workspaces you don't necessarily have to reference dependent packages in the consuming package's package.json. Like in our example, myapp has a dependency on lib-a, so we could list it in the dependencies section, but we don't have to:

apps/myapp/package.json
1{ 2 "name": "@ts-monorepo-linking/myapp", 3 ... 4 "dependencies": { 5 "@ts-monorepo-linking/lib-a": "*" // optional for NPM workspaces 6 } 7} 8

The * version specifier tells the package manager to resolve the dependency locally if available.

There's an exception to this:

  • publishable packages: Clearly, if you want to publish a package, it needs to have all its dependencies listed properly in the package.json.
  • PNPM workspaces: Due to how PNPM links packages, you need to reference the dependent packages in the consuming package's package.json. To avoid having to manually maintain such dependency in every consumer package.json you could resort to declaring such dependencies in the root package.json instead.

Also note that PNPM, Yarn v2+ and Bun support a dedicated "Workspaces Protocol" allowing you to prefix local dependencies with workspace:. This makes it more evident that the dependency is resolved locally. For example:

apps/myapp/package.json
1{ 2 "name": "@ts-monorepo-linking/myapp", 3 ... 4 "dependencies": { 5 "@ts-monorepo-linking/lib-a": "workspace:*" 6 } 7} 8

Observations: Workspaces and Project References

Dependency resolution:
With workspaces we delegate the package resolution to the package manager, making it independent of TypeScript. Unlike the previous solution with TypeScript path aliases, this approach works seamlessly at runtime since the package resolution is handled natively by Node.js or the package manager. This makes the setup more robust and platform-aligned.

Modularity:
Each package's package.json defines its public API and dependencies, making the structure explicit and easy to understand. One important detail is that in our example the package.json directly exports TypeScript files, making the consumer responsible for transpilation and bundling. As a result, these libraries are primarily intended for local use within the monorepo workspace.

Performance:
Compared to previous solutions, performance remains largely the same in this setup. The TypeScript project references still handle incremental type checking and compilation, which guarantees performance improvements. Package resolution is handled by the package manager's workspaces feature and Node itself, so it doesn't impact TypeScript's performance.

Using TypeScript Project References, Workspaces and Pre-building Packages

Example: Stackblitz - Github

In the previous setup, the lib-a package directly exported TypeScript files through its package.json:

packages/lib-a/package.json
1{ 2 "name": "@ts-monorepo-linking/lib-a", 3 ... 4 "type": "module", 5 "exports": { 6 ".": { 7 "types": "./src/index.ts", 8 "import": "./src/index.ts", 9 "default": "./src/index.ts" 10 }, 11 "./package.json": "./package.json" 12 }, 13 "main": "./src/index.ts", 14 "types": "./src/index.ts" 15} 16

This configuration works well for local monorepo use cases but delegates the responsibility of bundling to the consumer. To avoid that, you can pre-compile the package. You'll need to:

  • Adjust our lib-a's package.json to point to the compiled artifacts in dist.
  • Setting up a pre-compilation step.
  • Ensure all projects are compiled in the correct order based on their dependencies.

In our simple setup, the TypeScript project references already establish a dependency graph. Running tsc --build from the root of the workspace ensures that projects are compiled in the correct order based on their dependencies.

In a more complex setup you might need to rely on additional tooling such as Nx that has a task pipelines functionality built-in.

The resulting structure of the dist folder looks like this: (notice the *.js and *.d.ts files)

1ts-monorepo-linking 2 ├─ apps 3 │ └─ myapp 4 │ ├─ ... 5 │ ├─ package.json 6 │ └─ tsconfig.json 7 ├─ package.json 8 ├─ packages 9 │ └─ lib-a 10 │ ├─ dist 11 │ │ ├─ index.d.ts 12 │ │ ├─ index.d.ts.map 13 │ │ ├─ index.js 14 │ │ ├─ index.js.map 15 │ │ └─ tsconfig.tsbuildinfo 16 │ ├─ package.json 17 │ ├─ src 18 │ │ └─ index.ts 19 │ └─ tsconfig.json 20 ├─ tsconfig.base.json 21 └─ tsconfig.json 22

To make the compiled lib-a usable for other packages, we need to update its package.json to point to the compiled artifacts in dist. Here's the updated version:

packages/lib-a/package.json
1{ 2 "name": "@ts-monorepo-linking/lib-a", 3 "version": "0.0.0", 4 "private": true, 5 "type": "module", 6 "exports": { 7 ".": { 8 "types": "./dist/index.d.ts", 9 "import": "./dist/index.js", 10 "default": "./dist/index.js" 11 }, 12 "./package.json": "./package.json" 13 }, 14 "main": "./dist/index.js", 15 "types": "./dist/index.d.ts", 16 "module": "./dist/index.js" 17} 18

Observations: Pre-building Packages

Dependency resolution:
Precompiling dependent packages allows the application bundler to rely on prebuilt outputs, avoiding the need to compile package dependencies during application bundling. A task pipeline ensures that packages are compiled beforehand, streamlining the workflow.

Modularity:
Compared to the previous approach of directly referencing TypeScript source files, this setup slightly increases modularity. Each package is now self-contained, with its compiled outputs and defined entry points in the package.json. By precompiling and packaging the library, it can be distributed outside the monorepo if needed, which enhances its modularity and reusability. However, the primary focus remains internal use within the monorepo.

Performance:
This setup can slightly improve type-checking performance, especially within code editors. Since the type information is already generated as .d.ts files during precompilation, the editor can directly rely on these instead of processing TypeScript source files through project references. While cached project references can achieve similar speeds, precompiled declaration files might potentially reduce some overhead.

Which One Should I Choose?

Here are some thoughts on which approach to use.

TypeScript Path Aliases: A Simple Option

TypeScript path aliases have been a reliable way to manage package resolution, particularly before package managers introduced the workspaces feature. They're straightforward to set up, requiring only a global tsconfig.json without additional. However, there are limitations to consider as they require additional bundling support/alias resolvers at runtime and might come with some performance degradation in large workspaces.

Isn't Nx using TS Path Aliases?

Workspaces: The Recommended Approach

With widespread support in modern package managers (NPM, PNPM, Yarn, Bun), the workspaces feature has become the preferred method for managing package resolution. It aligns closely with the Node.js platform, leveraging native package resolution mechanisms that also work at runtime. This eliminates the portability issues inherent to TypeScript path aliases.

When combined with TypeScript project references, this method becomes even more powerful. Workspaces handles package linking such that Node can resolve them properly, while TypeScript project references optimize type-checking and enable incremental builds (for TypeScript). Together, they improve performance, reduce memory usage, and simplify dependency management in larger workspaces. This combination is the recommended way to structure and manage TypeScript packages in a monorepo.

To Prebuild or Not to Prebuild?

Prebuilding packages isn't always necessary. Modern bundlers like Vite and Rspack are optimized for speed, often making in-place compilation sufficient. Some things to consider:

  • Cost of Prebuilding: Precompiling packages introduces a small overhead, as each package must be built individually. Tools like Nx mitigate this cost with computation caching, allowing you to skip redundant builds. If cache results are available, builds can be significantly faster.
  • Selective Prebuilding: Prebuilding doesn't have to be applied universally. You can start without prebuilding and add it for specific subsets of your projects, such as the leaf nodes in your monorepo's project graph.
  • External Publishing: Prebuilding is essential if your packages need to be published outside the monorepo with tools like Nx release.

Are There Any Downsides to TypeScript Project References?

While TypeScript project references offer significant benefits, they can be maintenance-heavy, especially in large workspaces where their incremental type-checking capabilities are most valuable. The challenge lies in keeping the references array in each tsconfig.json file up to date, ensuring all project dependencies are correctly linked.

This is where Nx comes in, eliminating much of the manual effort involved in maintaining TypeScript project references:

  • Automated Setup with Generators: Nx provides generators for scaffolding applications and library packages. These generators handle the tsconfig.json setup automatically, ensuring that TypeScript project references are correctly configured from the start.
  • Automatic Synchronization: Nx includes a sync command that is automatically triggered before critical operations like building or serving a project. This command verifies whether the TypeScript project references are in sync across the workspace. If discrepancies are found, Nx automatically updates the references arrays, keeping your configuration consistent and accurate without manual intervention.

Wrapping Up

We've explored various strategies for configuring TypeScript-based packages in a monorepo, starting with relative imports, moving to TS path aliases, and finally leveraging the workspaces in combination with TypeScript project references.

If you want to try these approaches, check out the companion GitHub repository at https://github.com/juristr/ts-monorepo-linking, or create a new workspace with Nx:

npx create-nx-workspace mymonorepo --workspaces

Note --workspaces is a temporary flag to instruct Nx to generate a workspaces based monorepo setup.

Also check out our docs:


Learn More