Back to Blog
EngineeringJuly 22, 2026·12 min

Surviving TypeScript at Scale: Module Boundaries, Performance, and the tsconfig Trap

TypeScript scales fine—until your monorepo hits 500+ modules. Then your editor lags, your CI type-checks for 12 minutes, and nobody knows which barrel imports are safe. Here's what we learned the hard way.

typescriptmonorepoperformancetoolingarchitecturemodule-resolutionfrontendbackend

# Surviving TypeScript at Scale: Module Boundaries, Performance, and the tsconfig Trap

Two years ago, the frontend monorepo I inherited had 1,247 TypeScript files, a tsconfig.json that nobody had touched since 2023, and a node_modules directory clocking in at 1.8 GB. Opening the project in VS Code took 37 seconds. Changing a type in a shared utility file triggered a recompilation cascade that froze the editor for 8 seconds. tsc --noEmit on CI took 11 minutes and 42 seconds—longer than the actual test suite.

The instinct was to reach for project references, incremental builds, and whatever new compiler flag the TypeScript team had shipped that quarter. We tried all of them. Some helped. Some made things worse.

What follows is not a list of tips. It is a taxonomy of the real problems that emerge when TypeScript grows past the size of a small side project, with concrete solutions I have validated across three separate large-scale codebases.


Problem One: The Barrel Import Tax

A barrel file is an index.ts that re-exports everything from a directory:

// shared/ui/index.ts
export { Button } from "./Button";
export { TextField } from "./TextField";
export { Modal } from "./Modal";
export { Dropdown } from "./Dropdown";
// ... 30 more exports

Barrel files feel tidy. They give consumers a clean import path:

import { Button, Modal } from "@/shared/ui";

The problem is that barrel files defeat tree-shaking at the type-checking level. When you import from @/shared/ui, TypeScript resolves the barrel file, which means it must load and parse every re-exported module before it can check anything. If you only use Button, TypeScript still parses Dropdown, TextField, Modal, and every transitive dependency those files import.

At 500+ barrels, this creates an O(n²) resolution graph. Every barrel fans out to every dependency, and your editor has to chase the full graph before it can show you a type error on line 3.

The Fix: Direct Imports and ESLint Enforcement

Replace barrel re-exports with direct imports where performance matters:

// Instead of:
import { Button, Modal } from "@/shared/ui";

// Do this:
import { Button } from "@/shared/ui/Button";
import { Modal } from "@/shared/ui/Modal";

Enforce it with eslint-plugin-import:

{
  "rules": {
    "import/no-internal-modules": ["error", {
      "allow": ["@/shared/ui/**"]
    }],
    "no-restricted-imports": ["error", {
      "patterns": [{
        "group": ["@/shared/ui"],
        "message": "Import from specific files, not barrel. This saves 40% on type-checking time."
      }]
    }]
  }
}

The result on our codebase: tsc --noEmit dropped from 11:42 to 7:18. Forty percent improvement from changing nothing but import style.

Barrel files are not inherently evil. Use them selectively—for public API surfaces that are explicitly designed to be consumed as a unit (a library's public API, a feature module's export boundary). But do not barrel every components/index.ts because it looks tidy.


Problem Two: The Monolithic tsconfig Trap

Most large TypeScript projects start with a single tsconfig.json in the project root. This is fine for small projects. At scale, it is a performance catastrophe because TypeScript uses the strictest configuration found in any referenced path when resolving node_modules.

The specific trap is "strict": true combined with a broad "include" pattern:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "moduleResolution": "bundler",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*", "tests/**/*", "scripts/**/*"]
}

This configuration forces TypeScript to type-check scripts/ files with the same strictness as src/ application code. But more insidiously, it means that every compiler invocation loads every file, even if you only want to check a subset.

The Fix: Layered tsconfigs with Project References

// tsconfig.json (root — the "solution" config, used only as a reference parent)
{
  "files": [],
  "references": [
    { "path": "./tsconfig.src.json" },
    { "path": "./tsconfig.tests.json" },
    { "path": "./tsconfig.scripts.json" }
  ]
}
// tsconfig.src.json
{
  "compilerOptions": {
    "strict": true,
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*"]
}
// tsconfig.tests.json
{
  "extends": "./tsconfig.src.json",
  "compilerOptions": {
    "strict": false,         // Tests don't need full strictness
    "noUncheckedIndexedAccess": false,
    "types": ["vitest/globals"]
  },
  "include": ["tests/**/*"],
  "references": [{ "path": "./tsconfig.src.json" }]
}

Project references give TypeScript the information it needs to perform incremental builds—it only re-checks files that changed and their transitive dependents. On our CI pipeline, this cut type-check time from 7+ minutes to under 2 for most commits.

Caveat: Project references introduce complexity. tsc --build becomes your build command instead of tsc. Watch mode needs tsc --build --watch. And you must commit the .tsbuildinfo files to get incremental builds working in CI (cache them, obviously). The tradeoff is worth it past ~200 files.

Problem Three: Uncontrolled Module Boundary Leakage

The largest source of pain in a TypeScript monorepo is not the language—it is the lack of enforced module boundaries. In a well-structured codebase, the auth/domain module should not import from ui/components/widgets. But TypeScript will let you do it, because TypeScript does not enforce architectural boundaries by default.

This leads to a codebase where:

  1. Every module imports from everywhere else.
  2. Changing anything in shared utilities requires auditing 400 call sites.
  3. The dependency graph becomes a fully connected mesh, making incremental type-checking impossible (every change is "global").

The Fix: Enforce Module Boundaries Tooling

The two tools that actually work in production:

1. @typescript-eslint/no-restricted-imports for simple cases:
// .eslintrc.cjs
module.exports = {
  rules: {
    "@typescript-eslint/no-restricted-imports": ["error", {
      zones: [
        // Presentation cannot import from infrastructure
        {
          target: "src/presentation/**",
          from: "src/infrastructure/**",
          message: "Presentation layer must not import infrastructure directly. Use the domain layer as intermediary."
        },
        // Domain cannot import from outside itself
        {
          target: "src/**/domain/**",
          from: "!(src/**/domain/**)",
          message: "Domain modules must not depend on external layers."
        }
      ]
    }]
  }
};
2. nx boundary rules for monorepos (or barrelsby + dependency-cruiser):
// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: "no-cross-feature-imports",
      from: { path: "^src/features/([^/]+)" },
      to: {
        path: "^src/features/([^/]+)",
        pathNot: "^src/features/\\1"
      },
      comment: "Features must not import from other features. Extract shared code to src/shared/."
    },
    {
      name: "no-circular",
      severity: "error",
      from: {},
      to: { circular: true }
    }
  ]
};

On our codebase, adding dependency-cruiser to CI caught 47 circular dependencies in the first week. Some had existed for years. Every circular dependency makes incremental type-checking less efficient and makes isolatedModules builds (used by esbuild, swc, Bun) potentially unsound.


Problem Four: TypeScript Compiler Performance vs. Transpiler-Only Builds

Here is a contentious take: if your project is large enough to suffer from the problems above, you should probably not use tsc for production builds.

Build Tool    | 1000-file build (cold) | 1000-file build (incremental) | Type-checking
--------------|----------------------|------------------------------|--------------
tsc           | 47.3s                | 8.2s                         | ✅ Yes
swc           | 3.1s                 | 0.8s                         | ❌ No (strip types only)
esbuild       | 2.8s                 | 0.6s                         | ❌ No (strip types only)
Bun           | 1.9s                 | 0.4s                         | ❌ No (strip types only)

The numbers are from our production monorepo (1,027 TypeScript files, ~180K lines). All measurements are with project references enabled.

The approach that works:

  1. Use tsc --noEmit in CI to check types (separate step, can run in parallel with tests).
  2. Use swc or esbuild for compiling to JavaScript. Both are 10-15x faster because they only strip types.
  3. Run type-checking on a schedule or on merge queue, not on every push to every branch.

// .github/workflows/ci.yml
{
  "jobs": {
    "type-check": {
      "if": "github.event_name == 'pull_request' && !github.event.pull_request.draft",
      "runs-on": "ubuntu-latest",
      "steps": [
        "uses: actions/checkout@v4",
        "uses: actions/setup-node@v4",
        "run: npm ci",
        "run: npx tsc --noEmit",
        "run: npx depcruise src"
      ]
    },
    "build": {
      "steps": [
        // ... install deps
        "run: npx swc src --out-dir dist"
      ]
    }
  }
}
Important: This approach requires @swc/core or esbuild as your build tool. You lose const enum inlining and a few other esoteric TypeScript features. For 99.9% of codebases, this is fine. For the 0.1% that rely on const enum to inline values across module boundaries, you have bigger problems.

Problem Five: isolatedModules and the verbatimModuleSyntax Requirement

When you switch to transpiler-only builds (swc, esbuild), every file is compiled in isolation—there is no cross-file type information at build time. This breaks certain TypeScript patterns:

// This breaks with isolatedModules:
export enum Status {
  Active = "active",
  Inactive = "inactive",
}

// This also breaks:
export namespace API {
  export const BASE_URL = "https://api.example.com";
}

Swc re-writes enums to runtime constructs (it does not inline them like tsc does). Namespaces are silently converted to IIFE-wrapped objects. The result is functionally identical but slightly slower at runtime.

The more impactful change is verbatimModuleSyntax:

// tsconfig.json
{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

This flag, introduced in TypeScript 5.0, requires you to use explicit type imports:

// ❌ This fails with verbatimModuleSyntax:
import { User, getUser } from "./user";
// TypeScript cannot tell at parse time if User is a type or a value.

// ✅ Explicit type import:
import type { User } from "./user";
import { getUser } from "./user";

Adopting verbatimModuleSyntax is a genuine pain—it requires changing thousands of imports across the codebase. But it is also the single most impactful thing you can do for build performance, because it lets transpilers (and bundlers) know exactly what to elide.

Use @typescript-eslint/consistent-type-imports with fixStyle: "inline-type-imports" to auto-fix most cases:

// .eslintrc.cjs
module.exports = {
  rules: {
    "@typescript-eslint/consistent-type-imports": ["error", {
      prefer: "type-imports",
      fixStyle: "inline-type-imports"
    }]
  }
};

This auto-fixes to:

import { getUser, type User } from "./user";

Run it once with --fix across the whole codebase. It will hurt for an afternoon. The next day, your build is 3x faster and your production bundle is smaller because bundlers can safely elide unused type imports.


The Checklist for TypeScript at Scale

If you are maintaining a TypeScript codebase with more than 200 files, here is your action plan, ordered by impact:

PriorityActionImpactEffort
1Enable verbatimModuleSyntax and auto-fix importsBuild time: -60%One afternoon
2Ban barrel imports with ESLint rulesType-check: -40%Low (config change + PRs)
3Split into layered tsconfigs with project referencesIncremental builds: -70%Medium (restructure)
4Switch to swc/esbuild for builds, keep tsc for checkingCompile time: -90%Low (config change)
5Add dependency-cruiser to enforce module boundariesArchitecture decay: stoppedLow (add to CI)
6Adopt nx or turborepo for task orchestrationCI cache: -80%Medium (migration)

Do not try to do all six in one week. Start with priority 1 and 2—they are configuration changes with auto-fix tools, and they give the largest immediate wins. Add project references and dependency-cruiser as you pay down architectural debt.


The Thing Nobody Says Out Loud

TypeScript is an incredible language. It has genuinely made the JavaScript ecosystem better at scale. But it was designed in 2012, when a "large" codebase was 50,000 lines and a monorepo meant "a few people working in the same repo." The performance model—parsing the entire project graph to give you perfect feedback—does not scale to the 500,000-line monorepos that are standard at mid-size companies today.

The ecosystem has adapted: swc, esbuild, and Bun exist specifically because tsc stopped being adequate for development iteration speed. verbatimModuleSyntax exists because bundlers needed TypeScript to be honest about what is a type. Project references exist because one-shot full-project compilation is too slow for large codebases.

These are not bugs. They are growing pains. And the fix is not to abandon TypeScript—it is to stop treating it like a simple compiler and start treating it like the sophisticated static analysis engine it actually is. That means configuring it deliberately, enforcing boundaries proactively, and choosing your build tool for the specific job each tool does best.

The monorepo I inherited takes 2 minutes to type-check now, not 12. The build takes 4 seconds, not 47. The editor opens in 5 seconds. None of this required abandoning TypeScript or rewriting in another language. It required understanding how the tool actually works at scale—and being willing to give up a few syntactic conveniences (barrel imports, implicit type imports) in exchange for a codebase that doesn't fight you.

What to do next: Pick one item from the priority table above. Implement it this week. Measure the before-and-after with time npx tsc --noEmit. You will not need convincing after you see the numbers.

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch