TypeScript Monorepo Guide: Project References, Path Aliases, and Package Boundaries
monorepoproject referencespath aliasestypescript toolingpackage boundariesscaling

TypeScript Monorepo Guide: Project References, Path Aliases, and Package Boundaries

TTypeScript Website Editorial
2026-06-11
10 min read

A practical checklist for scaling a TypeScript monorepo with project references, path aliases, and clear package boundaries.

A TypeScript monorepo can make shared code easier to manage, but it also introduces new failure modes: slow builds, confusing imports, accidental cross-package coupling, and type boundaries that look clean until release time. This guide gives you a reusable checklist for setting up and maintaining a monorepo with TypeScript project references, path aliases, and clear package boundaries. The goal is not a trendy tool-specific recipe. It is a long-term setup that stays useful as package managers, build tools, and framework defaults change.

Overview

If you are deciding how to structure a growing codebase, start with one principle: a monorepo is a repository strategy, not an excuse to blur package boundaries. TypeScript can help you scale a monorepo well, but only if the repository layout, compiler configuration, and import rules all point in the same direction.

For most teams, the three concepts that matter most are:

  • Project references: Tell TypeScript how packages depend on each other so builds and type-checking can happen incrementally and in the right order.
  • Path aliases: Make imports easier to read, but only when they reflect real package boundaries rather than hide them.
  • Package boundaries: Define what each package can expose, what must stay internal, and how code moves between app code and shared libraries.

A good monorepo TypeScript setup usually has a root configuration for shared rules, one tsconfig.json per package or app, and a conscious decision about what counts as public API. If your imports, build graph, and runtime resolution disagree, the monorepo will feel brittle no matter which tool you use.

Before you configure anything, answer these questions:

  • Which folders are deployable apps, and which are reusable libraries?
  • Do shared packages need to be publishable, or only consumable inside the repo?
  • Will apps consume built output, source files, or both during development?
  • Do you want package names as imports, path aliases, or a mix of both?
  • Which layer owns domain types, API contracts, UI primitives, and infrastructure code?

If those answers are vague, the compiler setup will probably become vague too. For a broader baseline on compiler settings, see tsconfig.json Best Practices: Recommended Settings for Apps, Libraries, and Monorepos.

Here is a simple directory shape that works well for many repositories:

repo/
  apps/
    web/
    api/
  packages/
    ui/
    config/
    shared/
    types/
  tsconfig.base.json
  tsconfig.json

In this model, apps depend on packages, and packages may depend on lower-level packages. The important part is not the folder names. It is that dependencies should mostly flow in one direction.

A practical root setup often looks like this:

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "baseUrl": "."
  }
}
// tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/types" },
    { "path": "./packages/shared" },
    { "path": "./packages/ui" },
    { "path": "./apps/api" },
    { "path": "./apps/web" }
  ]
}

The root reference file acts as the build graph. Individual packages then extend the base configuration and declare their own references.

Checklist by scenario

Use this section as the part you come back to before making structural changes. The right TypeScript monorepo setup depends on what you are building, not just what the compiler supports.

Scenario 1: You are starting a new monorepo

  • Create a root tsconfig.base.json for shared compiler rules.
  • Create a root tsconfig.json that contains only project references.
  • Give every app and package its own tsconfig.json.
  • Set composite to true for referenced projects.
  • Enable declaration if packages are meant to expose typed public APIs.
  • Choose package-name imports first, and add path aliases only when they solve a real readability problem.
  • Define a single entry point per package such as src/index.ts.
  • Keep internal files internal unless there is a deliberate reason to expose subpath imports.

A minimal package config might look like this:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src"],
  "references": [
    { "path": "../types" }
  ]
}

This gives TypeScript enough information to type-check packages in dependency order and makes it easier to run incremental builds.

Scenario 2: You are migrating a large JavaScript repo into a TypeScript monorepo

  • Do not convert everything at once. Establish package boundaries first.
  • Pick the packages with the clearest dependencies and strongest reuse value.
  • Add TypeScript configs package by package, even if some code stays JavaScript for a while.
  • Use allowJs temporarily only where it reduces migration friction.
  • Avoid global path aliases that make every folder importable from every other folder.
  • Introduce project references after package ownership and dependency direction are clear.
  • Document which imports are temporary migration shortcuts and when they must be removed.

If you are moving Node services into this model, the runtime side matters too. Compiler settings alone do not solve ESM, CJS, or output-format decisions. For that, see Node.js with TypeScript: Project Structure, ESM vs CJS, and Build Setup.

Scenario 3: You have multiple apps sharing types and utilities

  • Create separate packages for domain types, utilities, and framework-specific components instead of one catch-all shared folder.
  • Keep pure TypeScript packages framework-agnostic where possible.
  • Use project references so app builds know that shared packages are upstream dependencies.
  • Export stable types from package entry points; avoid deep imports into internal files.
  • Consider whether API types belong in a dedicated contract package.

This is especially useful when you want one API layer and multiple clients to agree on request and response shapes. If that is part of your architecture, How to Type API Responses in TypeScript for REST, GraphQL, and Fetch Clients is a helpful companion.

Scenario 4: You want path aliases because relative imports are getting messy

  • Use path aliases to improve clarity, not to bypass package boundaries.
  • Prefer aliases that map to package entry points, such as @repo/shared, rather than internal folders like @shared/utils/private.
  • Make sure your runtime, bundler, test runner, and editor all resolve aliases the same way.
  • Keep aliases consistent with package names if possible.
  • Avoid using aliases that let one app import another app's internals.

A common pattern looks like this:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@repo/types": ["packages/types/src/index.ts"],
      "@repo/shared": ["packages/shared/src/index.ts"],
      "@repo/ui": ["packages/ui/src/index.ts"]
    }
  }
}

This can work well in development, but remember that TypeScript path aliases are not a full runtime strategy by themselves. If your bundler or Node environment does not resolve the same aliases, the imports will type-check and still fail later. Use aliases carefully, and prefer real workspace package imports when your toolchain supports them cleanly.

Scenario 5: You are building libraries inside the monorepo

  • Treat each library as a product with a public API surface.
  • Export only supported entry points.
  • Generate declarations and test them from consuming packages.
  • Keep React-specific, Node-specific, and browser-specific code in separate packages unless there is a reason to combine them.
  • Do not let app-only environment assumptions leak into shared code.

For UI packages, a clean component API matters as much as a clean build graph. See React with TypeScript: Best Practices for Props, Hooks, and Component APIs if your shared packages include React components, and Next.js with TypeScript: App Router Patterns, Server Actions, and Type Safety if one of your consumers is a Next.js app.

Scenario 6: You need package boundaries to stay enforceable

  • Decide which packages are foundational, which are domain-level, and which are app-level.
  • Write import rules that prevent upward or sideways dependencies where they do not belong.
  • Prefer dependency direction such as types -> shared -> feature packages -> apps.
  • Use lint rules, workspace constraints, or custom checks to block forbidden imports.
  • Review new packages for ownership and API design before adding them to the repo.

TypeScript helps express intent, but package boundaries usually need non-TypeScript enforcement too. A compiler can verify types; it cannot decide your architecture for you.

What to double-check

Once the basic setup is in place, these are the items most likely to cause confusing behavior later.

1. Do references match real dependencies?

If package A imports package B, its references should reflect that relationship. When references are incomplete, builds can appear to work until incremental compilation, CI caching, or editor diagnostics behave inconsistently.

2. Are you importing source files or built packages?

Pick a model and document it. Importing source can improve development feedback, but consuming built output more closely matches production behavior. Mixing both without a clear rule often causes declaration mismatches and duplicate compilation.

3. Are path aliases duplicating workspace package names?

In many monorepos, the cleanest import is the actual package name. If you also define a TypeScript-only alias for the same package, developers may not know which form is canonical. Prefer one import style per package.

4. Is your public API narrower than your file tree?

Every extra import path becomes a support burden. If consumers import internal files directly, refactoring gets harder. Expose a stable entry point and treat deep imports as an exception, not the default.

5. Are compiler options aligned across packages?

Packages do not need identical settings, but important behavioral flags should not vary by accident. Differences in strictness, module resolution, JSX handling, or emit behavior can create subtle edge cases. Revisit the shared baseline periodically using a guide like tsconfig.json Best Practices.

6. Are runtime validation and compile-time types being confused?

Shared types across packages are useful, but they do not validate data at runtime. If packages exchange external input, API payloads, or database data, pair your TypeScript types with runtime validation where needed. Zod vs Yup vs Valibot: Runtime Validation Libraries for TypeScript Compared can help you decide where that belongs in the monorepo.

7. Are generic utilities becoming a dumping ground?

Many monorepos end up with a shared package that slowly becomes impossible to reason about. Split it when code serves different concerns. Domain types, generic helpers, UI tokens, and API contracts usually age better in separate packages. If your shared code leans heavily on reusable type abstractions, Generics in TypeScript: Practical Patterns for Functions, APIs, and Components is worth reviewing.

Common mistakes

These issues show up often in real monorepo TypeScript setups, especially after a period of fast growth.

  • Using path aliases as architecture. Aliases improve import syntax; they do not define safe dependency layers.
  • Creating one giant shared package. This feels convenient early on and expensive later.
  • Skipping project references in a large repo. TypeScript can still run, but builds and editor performance often become harder to manage.
  • Allowing deep imports into package internals. This undermines public API design and makes refactors risky.
  • Letting apps import each other. Shared code should move into packages instead.
  • Assuming type-check success means runtime success. Alias resolution, module format, and build output still need to match the execution environment.
  • Inconsistent tsconfig inheritance. If packages copy settings instead of extending a shared base, drift appears quickly.
  • Not defining ownership. A package without clear ownership tends to accumulate unrelated exports.

If you are debugging the fallout from any of these, a focused error review can save time. TypeScript Error Guide: Common Compiler Errors and How to Fix Them is a useful reference when the build graph and type system start producing noisy diagnostics.

A simple rule can prevent many of these problems: if an import feels convenient but makes package responsibility less clear, pause before adding it. Convenience in a monorepo is valuable, but only if it preserves the structure you want six months from now.

When to revisit

Your monorepo setup should not be rewritten constantly, but it should be reviewed at specific moments. This is the checklist to return to before planning cycles or whenever workflows change.

  • When a new app is added: confirm dependency direction, package reuse goals, and whether new shared code belongs in an existing package or a new one.
  • When frameworks change: recheck module resolution, JSX settings, and any framework-specific package assumptions.
  • When build tools or package managers change: verify that alias resolution, workspace linking, and TypeScript references still agree.
  • When CI performance becomes a complaint: inspect project references, incremental builds, and packages that trigger broad rebuilds.
  • When teams multiply: tighten package ownership and import rules before the repository becomes socially rather than technically coupled.
  • When release boundaries change: revisit which packages are internal only and which need stable public APIs.
  • When shared types start leaking implementation details: split domain contracts from internal utility types.

Here is a short action plan you can use on your next review:

  1. List every app and package in the repository.
  2. Draw the current dependency graph.
  3. Mark any app-to-app imports, deep imports, or circular dependencies.
  4. Decide the canonical import path for each package.
  5. Confirm that project references mirror real dependencies.
  6. Check whether each package has a stable public entry point.
  7. Move mixed-purpose shared code into clearer packages where needed.
  8. Align root and package-level TypeScript settings.
  9. Test one clean install, one full build, and one editor workflow from scratch.
  10. Document the rules so future packages follow the same model.

The best TypeScript monorepo setup is usually the one that stays understandable under change. Project references should make dependency order explicit. Path aliases should improve readability without hiding architecture. Package boundaries should be strict enough to protect the repository and simple enough that developers remember them without reading a diagram every day.

If you treat this article as a checklist rather than a one-time tutorial, it will stay useful as your monorepo grows. The exact tools may change. The core questions usually do not.

Related Topics

#monorepo#project references#path aliases#typescript tooling#package boundaries#scaling
T

TypeScript Website Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-06-12T12:01:52.890Z