November 3, 2026
App Development

TypeScript at Scale: Monorepos, Strictness, and Speed

TypeScript scales in large monorepos through incremental strict-mode adoption, project references for isolated builds, and delegating type-checking away from tsc’s bottleneck to faster transpilers. Pair this with Nx or Turborepo’s task caching and clear shared-type ownership, and a 500-package monorepo can type-check nearly as fast as a 5-package one.
Myth vs. reality: TypeScript in large monorepos
MythReality
“Enabling strict mode is an all-or-nothing switch.”TypeScript’s strictness flags can be enabled individually and per-project, letting large codebases adopt noImplicitAny, then strictNullChecks, then full strict incrementally over months rather than in one disruptive change.
“Project references are just a build-speed optimization.”Project references also enforce architectural boundaries — a package can only import from another package if it’s explicitly referenced, which prevents accidental circular dependencies that plague large monorepos.
“SWC and esbuild replace the TypeScript compiler entirely.”SWC and esbuild strip types and transpile fast, but neither actually type-checks your code. Most production setups still run tsc –noEmit separately (often in CI or via project references) for the type-checking pass itself.
“Nx and Turborepo are competing with TypeScript’s own tooling.”Both build on top of TypeScript’s project references and tsconfig structure rather than replacing them — their value is task orchestration and caching layered over the existing TypeScript build graph.
“isolatedModules is only relevant for Babel users.”isolatedModules is now commonly required by fast, per-file transpilers generally (SWC, esbuild included), since none of them can see across files to resolve certain TypeScript-only constructs the way tsc can.

Why TypeScript at scale is a different problem than TypeScript on a small project

TypeScript on a single-package project is mostly a question of syntax and editor support. TypeScript across a monorepo with dozens or hundreds of packages, multiple teams, and a shared CI budget is a completely different engineering problem: it’s a question of build topology, incremental compilation, ownership boundaries, and the tradeoff between type-checking rigor and the minutes an engineer waits for feedback. Get this wrong and a monorepo’s TypeScript setup becomes the single most complained-about piece of infrastructure in the company; get it right and TypeScript becomes close to invisible — fast, reliable, and quietly catching real bugs before they reach code review. Much of this is really a collaboration problem as much as a compiler problem, which is why the review and branching discipline covered in our guide to using Git and version control in collaborative app projects tends to matter just as much as the TypeScript configuration itself once a team crosses a certain size.

Strict mode adoption strategies for large, existing codebases

The biggest mistake teams make with strict mode is treating it as a binary switch to flip on an entire monorepo at once. TypeScript’s strict flag is actually a bundle of several independent flags — noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, and a few others — and each can be enabled individually. For a large existing codebase, the realistic adoption path is incremental: enable noImplicitAny first since it catches the most common and easiest-to-fix looseness, stabilize, then enable strictNullChecks — historically the flag with the largest blast radius because it forces every nullable value to be handled explicitly — and only add the remaining flags once the codebase has absorbed those two.

For genuinely large monorepos, a per-package strictness gradient works better than a single global setting. New packages are created with full strict: true from day one via a shared base tsconfig.json that all new packages extend. Legacy packages keep a looser configuration until a dedicated migration effort (often tracked as tech debt with an actual ticket and owner) brings them up to the same bar. TypeScript’s per-directory tsconfig.json overrides, combined with project references, make this gradient explicit and visible in the repository structure itself rather than hidden in a single sprawling configuration file nobody wants to touch.

Figure 1: A realistic strict-mode rollout timeline for an existing large codebase

A timeline would show four stages spread across roughly two to three quarters: Stage 1, enable noImplicitAny repo-wide and fix the resulting errors package by package; Stage 2, enable strictNullChecks starting with the most frequently modified, highest-risk packages first (since bugs caught there have the highest payoff); Stage 3, enable the remaining strict flags on packages that passed stages 1 and 2 cleanly; Stage 4, flip the shared base config’s default to full strict: true so every new package inherits full strictness automatically, while a shrinking list of legacy packages carries an explicit, tracked exception.

Project references: the mechanism that makes large-scale TypeScript builds tractable

Project references are TypeScript’s own native answer to the monorepo build problem, and they predate most of the third-party monorepo tooling built on top of them. A project reference is declared in a package’s tsconfig.json via the references field, pointing at the other packages it depends on. Each referenced project must set composite: true, which forces it to emit declaration files (.d.ts) and a build info file that TypeScript uses to determine whether a downstream package actually needs to be rebuilt.

The practical effect is twofold. First, build performance improves substantially because TypeScript’s incremental build orchestrator (tsc –build, often shortened to tsc -b) can skip re-checking any package whose inputs haven’t changed, walking the dependency graph and only recompiling what’s actually affected by a given change — this is the same fundamental idea as a monorepo task runner’s caching, just implemented natively inside the compiler. Second, and less discussed, project references enforce a real architectural boundary: a package cannot import from another package’s internals unless it is declared as a reference, which catches accidental circular dependencies and unintentional coupling at compile time rather than letting them accumulate silently until someone tries to untangle the dependency graph months later.

Build performance approaches compared
ApproachType-checks?Typical speedBest used for
tsc (standard compile)Yes, full checkingSlowest of the fourCI type-checking gate, local full validation
tsc –build with project referencesYes, full checkingFast on incremental rebuilds; slow on first buildMonorepo-wide incremental type-checking
SWCNo — strips types onlyVery fast, often 10-20x tsc for transpilationTest runners, dev-server transpilation, bundling
esbuildNo — strips types onlyVery fast, comparable to SWC for most workloadsBundling, dev servers, quick build steps

Build performance: tsc vs. SWC vs. esbuild vs. isolatedModules

The core tension in large-monorepo TypeScript build performance is that tsc is the only tool in this list that actually type-checks your code — it builds a full program graph, resolves every type, and reports errors. SWC and esbuild are transpilers: they strip TypeScript type annotations and emit JavaScript quickly, on a per-file basis, without ever building a whole-program type graph. This makes them dramatically faster for the specific job of “turn this TypeScript file into runnable JavaScript” but means they cannot catch a type error — that job still belongs to tsc, typically run separately, often only in CI or as a background watch process, decoupled from the fast transpile-and-run loop developers use locally.

This split is why isolatedModules matters so much in any setup using SWC or esbuild. Because these tools transpile file-by-file with no cross-file knowledge, certain valid TypeScript patterns — re-exporting a type without the type keyword, or relying on cross-file const enum inlining — cannot be transpiled correctly without seeing the whole program. The isolatedModules compiler flag makes TypeScript itself flag any code that would break under a per-file transpiler, so teams can safely adopt SWC or esbuild for their fast path without silently shipping subtly broken output. Real-world benchmarks bear out the magnitude of the speed difference: per-file transpilation with SWC or esbuild routinely lands somewhere between 4x and 25x faster than a Babel-based pipeline for equivalent transpilation work, and comparably faster than running the full tsc compiler for the same task, which is exactly why virtually no large-scale 2027 TypeScript monorepo relies on tsc alone for its everyday build and test loop. Many of these same build-tool tradeoffs, including where Next.js and Node.js fit into a modern toolchain, are covered in more general terms in our roundup of open-source web development tools.

The practical architecture most large teams converge on is a two-track pipeline: a fast track using SWC or esbuild (via a tool like ts-node‘s SWC-powered mode, Vite, or a bundler’s build step) for local development, hot reloading, and running the test suite quickly, and a separate, slower type-checking track using tsc –build with project references, run on save in the editor via the TypeScript language server and as a required, cached CI gate before merge. Neither track replaces the other; each solves a different half of the problem.

Type-safety patterns that hold up for large teams

Type safety at the scale of dozens of contributing engineers is less about clever generics and more about consistent, boring conventions that prevent an escape hatch from becoming a habit. A few patterns consistently separate large TypeScript codebases that stay maintainable from ones that don’t:

Ban unchecked any at the lint level, not just by convention. An ESLint rule like @typescript-eslint/no-explicit-any, enforced in CI rather than left as a style suggestion, prevents the single most common way type safety erodes silently over time — a rushed any added under deadline pressure that never gets revisited.

Validate data at the boundary, not just at the type level. TypeScript’s types disappear at runtime, so any data crossing a real boundary — an API response, a form submission, an environment variable — needs runtime validation, typically via a schema library (Zod and similar libraries are the dominant pattern in 2027) that both validates the shape at runtime and derives the exact static TypeScript type from the same schema definition, so the two can never silently drift apart.

Own shared types in a dedicated package with a clear ownership model. Large monorepos that let every package define its own slightly-different copy of a “User” or “Order” type accumulate subtle incompatibilities that only surface as confusing type errors at integration points. A single shared-types package, referenced explicitly via project references, with a clear owning team, keeps the source of truth singular.

Treat generated types (from an API schema, a database, or a GraphQL schema) as the source of truth, not hand-written duplicates. Code generation from an OpenAPI spec, a Prisma schema, or a GraphQL schema removes an entire class of “the type and the actual API drifted apart” bugs that hand-maintained duplicate type definitions are prone to. This same tension between hand-written and generated type definitions shows up across the broader frontend landscape too, including in how React, Svelte, and Solid teams approach typed component props, covered in our comparison of React vs Svelte vs Solid in 2027.

Figure 2: Where type safety actually breaks down in large teams

A funnel diagram would show four common leak points, from most to least frequent in practice: untyped or loosely-typed boundaries with external systems (APIs, forms, environment variables) where runtime data enters the typed world; ad hoc any casts added under time pressure and never revisited; duplicated, slightly-inconsistent type definitions for the same real-world entity across different packages; and generic utility types that are technically correct but so complex that engineers route around them with a cast rather than working through the actual type error.

Monorepo tooling: how Nx and Turborepo interact with TypeScript’s own build system

Nx and Turborepo do not replace TypeScript’s compiler or its project-reference system — both are built to sit on top of it, adding task orchestration, dependency-graph awareness, and remote caching to whatever build steps a project already runs, including tsc itself. The core value each adds is content-aware caching: if a package’s source files, its dependencies’ outputs, and its configuration haven’t changed since the last build, the tool can restore the previous build’s output (locally or from a shared remote cache) instead of re-running the compiler at all. For a large monorepo, this is often a bigger win than any single compiler-level optimization, since it can reduce a CI pipeline’s real-world wall-clock time by a very large margin when most of a given change only touches a small slice of the dependency graph.

Nx leans toward being a more opinionated, full build-system replacement, with first-class generators and plugins that scaffold TypeScript project references correctly by default, which reduces the chance of a team misconfiguring the reference graph by hand. Turborepo takes a lighter-touch approach, focused specifically on task orchestration and caching while leaving more of the underlying TypeScript configuration to the team to manage directly. Neither choice is objectively superior; teams that want strong defaults and less manual TypeScript-config maintenance tend to prefer Nx, while teams that already have a well-understood, manually maintained TypeScript project-reference graph and just want fast, cached task execution on top of it tend to prefer Turborepo’s smaller footprint. Both integrate with remote caching services that let an entire engineering organization share build outputs, so one engineer’s CI run can benefit from a nearly identical build another engineer already produced minutes earlier.

Nx vs. Turborepo for a TypeScript-heavy monorepo
DimensionNxTurborepo
OpinionationHigher — generators scaffold project references and configLower — you manage tsconfig and references directly
Underlying implementationTypeScript/JavaScript, larger built-in plugin ecosystemWritten in Rust for core task execution performance
Best fitTeams wanting strong defaults and less manual configTeams with an existing, well-understood TS project-reference setup
Caching modelContent-aware local and remote cachingContent-aware local and remote caching
  • Step 1 — Establish a shared base tsconfig. Create one root-level strict configuration that every new package extends by default, so strictness is the path of least resistance rather than an opt-in.
  • Step 2 — Wire up project references deliberately. Map out real package dependencies and declare them explicitly, using this step to also catch and remove any accidental circular dependencies the codebase has accumulated.
  • Step 3 — Split your build into a fast track and a type-checking track. Use SWC or esbuild for day-to-day transpilation, testing, and local dev; keep a separate, cached tsc –build pass as the authoritative type-checking gate in CI.
  • Step 4 — Layer Nx or Turborepo on top for task caching. Configure remote caching so CI and local builds share results, turning a large fraction of routine builds into cache hits rather than fresh compiles.
  • Step 5 — Migrate legacy packages to full strictness on a tracked schedule. Treat each legacy package’s strictness gap as visible tech debt with an owner and a deadline, not an indefinite exception that quietly becomes permanent.

Common mistakes

  • Enabling full strict: true across an entire large, pre-existing monorepo in a single change, producing thousands of errors that get suppressed with blanket // @ts-ignore comments instead of actually fixed.
  • Relying solely on SWC or esbuild for “type checking” because the build succeeds, without realizing neither tool actually validates types — a genuine type error can ship to production silently in this setup.
  • Manually maintaining project references inconsistently across dozens of packages without a generator or linter enforcing the pattern, leading to a reference graph that drifts out of sync with actual import statements.
  • Treating Nx or Turborepo’s caching as a substitute for fixing a genuinely slow, poorly structured build, rather than as a multiplier on top of an already-reasonable build graph.
  • Letting every package define its own copy of core domain types instead of centralizing them, then spending significant engineering time debugging integration errors caused by two “identical” types that have quietly diverged.

In practice

Organizations that scale TypeScript successfully tend to treat the build-performance and type-safety problems as two separate workstreams that reinforce each other rather than one combined initiative. The build-performance workstream typically starts with introducing project references and a fast transpiler track, since that has the most visible, immediate payoff for developer experience — engineers feel the difference within days. The type-safety workstream runs in parallel but on a longer timeline: rolling out strict mode flag by flag, package by package, starting with the highest-churn, highest-risk code first so the payoff (bugs caught before code review) is visible early, which builds the organizational goodwill needed to keep the migration funded through the less glamorous long tail of legacy packages. Staffing this kind of migration is also increasingly a hiring question, since demand for engineers comfortable with large-scale TypeScript tooling has grown fast enough that it shows up in curricula covered in our piece on non-traditional paths into tech. Teams that try to do both simultaneously as a single big-bang project tend to stall, because the two workstreams have genuinely different success metrics — one is measured in CI minutes, the other in bugs prevented — and conflating them into a single initiative makes it hard to show incremental progress on either.

Glossary

Strict mode
A bundle of independent TypeScript compiler flags (including noImplicitAny and strictNullChecks) that together enforce the language’s strongest type-safety guarantees.
Project references
A TypeScript feature that lets a tsconfig.json declare dependencies on other TypeScript projects, enabling incremental, dependency-aware builds via tsc –build.
Composite project
A TypeScript project configured with composite: true, required for any project referenced by another, which forces it to emit declaration files usable by dependents.
isolatedModules
A compiler flag that flags TypeScript code relying on cross-file information that a per-file transpiler like SWC or esbuild cannot safely handle.
Transpiler (SWC/esbuild)
A tool that converts TypeScript to JavaScript by stripping type annotations file-by-file, without performing whole-program type checking.
Remote caching
A shared cache, used by tools like Nx and Turborepo, that lets multiple developers and CI runs reuse previously computed build or test outputs instead of recomputing them.
Task graph
The dependency graph a monorepo tool like Nx or Turborepo builds to determine which packages’ tasks need to run, and in what order, for a given change.

Key Takeaways

  • Strict mode should be adopted incrementally, flag by flag and package by package, rather than flipped on across an entire large codebase at once.
  • Project references are TypeScript’s native mechanism for both faster incremental builds and enforced architectural boundaries between packages.
  • SWC and esbuild transpile far faster than tsc but do not type-check; production setups pair a fast transpile track with a separate, cached tsc-based type-checking gate.
  • isolatedModules exists because per-file transpilers cannot safely handle certain cross-file TypeScript constructs, and enabling it catches those cases at compile time.
  • Durable type safety at scale depends more on boundary validation, banned unchecked any, and centralized shared types than on advanced generic type tricks.
  • Nx and Turborepo layer task orchestration and remote caching on top of TypeScript’s existing build system rather than replacing it.
  • Build-performance work and type-safety migration work are best run as two separate, parallel workstreams with different success metrics, not one combined initiative.

FAQs

Should we enable TypeScript’s full strict mode all at once in a large existing monorepo?

No. Enabling individual flags incrementally, starting with noImplicitAny and then strictNullChecks, lets a large team fix real issues in manageable batches instead of facing thousands of errors that get suppressed rather than fixed.

Do project references actually make builds faster, or just enforce structure?

Both. Project references let tsc –build skip recompiling packages whose inputs haven’t changed, which speeds up incremental builds substantially, while also enforcing that packages can only depend on explicitly declared references, catching accidental circular dependencies.

If we use SWC or esbuild, do we still need tsc at all?

Yes. SWC and esbuild strip types and transpile quickly but do not type-check. Most production setups still run tsc –noEmit or tsc –build separately, typically as a CI gate, to catch actual type errors that a fast transpiler would silently ignore.

What does isolatedModules actually protect against?

It flags TypeScript patterns that rely on the compiler seeing multiple files at once, such as certain type re-exports or const enum usage, which a per-file transpiler like SWC or esbuild cannot handle correctly since it only ever looks at one file at a time.

Is Nx or Turborepo required to scale TypeScript in a monorepo?

Not strictly required, since TypeScript’s own project references provide incremental builds natively, but both tools add meaningful value through task orchestration and remote caching across an entire team, which project references alone don’t provide.

What’s the biggest type-safety risk in a large team that isn’t about generics or advanced types?

Unvalidated data crossing real boundaries — API responses, form submissions, environment variables — is the most common source of runtime failures in supposedly type-safe codebases, since TypeScript’s types vanish at runtime and provide no protection there without an explicit runtime validation layer.

How should shared types be organized across many packages in a monorepo?

Centralize core domain types in a single, clearly owned shared-types package referenced explicitly by dependent packages, rather than letting each package define its own version of the same conceptual type, which reliably causes subtle integration bugs as the definitions drift apart.

Should build-performance work and strict-mode migration happen in the same project?

They tend to succeed better as separate, parallel workstreams with different owners and different success metrics — CI minutes saved for build performance, bugs prevented for type-safety migration — since combining them into one initiative makes it harder to show and sustain progress on either.

References

    Noah Berg

    author
    Noah earned a B.Eng. in Software Engineering from RWTH Aachen and an M.Sc. in Sustainable Computing from KTH. He moved from SRE work into measuring software energy use and building carbon-aware schedulers for batch workloads. He loves the puzzle of hitting SLOs while shrinking kilowatt-hours. He writes about greener infrastructure: practical energy metrics, workload shifting, and procurement choices that matter. Noah contributes open calculators for estimating emissions, speaks at meetups about sustainable SRE, and publishes postmortems that include environmental impact. When not tuning systems, he shoots 35mm film, bakes crusty loaves, and plans alpine hikes around weather windows.

      Leave a Reply

      Your email address will not be published. Required fields are marked *