Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Sharing a Base tsconfig Across Workspaces

Every package in a TypeScript monorepo needs a tsconfig.json, and without a shared base they drift: one package targets ES2019, another has strict off, a third uses a different moduleResolution, and type errors appear or vanish depending on which package you compile. A shared configuration package fixes that by giving every workspace one source of truth, extended with a few package-specific lines. This guide builds that package, explains how extends resolves paths, and shows how to structure bases for libraries, applications and tooling.

The problem a shared base solves

Configuration drift produces errors that are hard to trace because they depend on where the code is compiled from. Typical symptoms:

packages/ui/src/Button.tsx:14:9 - error TS2322: Type 'string | undefined' is not assignable to type 'string'.
# ...passes when the same file is type-checked from apps/web, which has strict: false
error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
# ...only in packages/api, which alone uses nodenext
error TS5095: Option 'bundler' can only be used when 'module' is set to 'preserve' or to 'es2015' or later.

The last one is a symptom of copying half a configuration from another package. A shared base removes the copying.

How extends works

extends loads another configuration and merges the local file on top. Three rules explain almost every surprise:

  1. compilerOptions merge key by key; the extending file wins for any key it sets.
  2. files, include and exclude do not merge — the extending file's arrays replace the base's entirely, and paths in the base are resolved relative to the base file's location.
  3. Relative paths in compilerOptions (outDir, rootDir, baseUrl, paths targets) resolve relative to the file that declares them. A base that sets "outDir": "dist" points at a dist folder next to the base, not next to your package — so path options belong in each package's own file.

Since TypeScript 5.0, extends accepts an array, applied left to right, and resolves package names through node_modules — so a base can live in a workspace package and be referenced by name.

How a package tsconfig is assembled Four layers from the shared strict base, through a library or app preset, to the package's own file with paths, and finally the effective configuration. 1 base.json strict, target, lib, isolatedModules, skipLibCheck — shared by everything 2 library.json / app.json module and moduleResolution per kind of package; declaration output for libraries 3 packages/ui/tsconfig. json rootDir, outDir, include, references — paths resolved from this file 4 effective config what tsc --showConfig prints for the package
Shared layers hold compiler behaviour; each package's own file holds only paths and a few overrides.

Building the shared configuration package

Create a private workspace package for the configurations:

packages/tsconfig/
  package.json
  base.json
  library.json
  app-bundler.json
  node-app.json
{
  "name": "@acme/tsconfig",
  "version": "0.0.0",
  "private": true,
  "files": ["*.json"],
  "exports": {
    "./base.json": "./base.json",
    "./library.json": "./library.json",
    "./app-bundler.json": "./app-bundler.json",
    "./node-app.json": "./node-app.json"
  }
}

The base holds behaviour every package shares:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "lib": ["es2023"],
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "noUncheckedIndexedAccess": true,
    "resolveJsonModule": true
  }
}

Presets add the module system for each kind of package:

// library.json — published packages that Node.js loads directly
{
  "extends": "./base.json",
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true
  }
}
// app-bundler.json — applications built by Vite, Next.js or webpack
{
  "extends": "./base.json",
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2023", "dom", "dom.iterable"],
    "jsx": "react-jsx",
    "noEmit": true
  }
}

Each package then carries only what is genuinely local:

{
  "extends": "@acme/tsconfig/library.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src"],
  "references": [{ "path": "../utils" }]
}

Add the configuration package as a dev dependency of every package with "@acme/tsconfig": "workspace:*", so the task graph knows about it and changes to the base invalidate caches.

One configuration package, many consumers The @acme/tsconfig package in the centre provides base, library, app and node presets to libraries, applications and tooling packages around it. @acme/tsconfig base + presets packages/ui library.json packages/utils library.json apps/web app-bundler.json apps/api node-app.json tooling/scripts node-app.json + noEmit
Presets encode the few real differences between package kinds; everything else comes from one base.

Choosing moduleResolution per package kind

The single most consequential setting is moduleResolution, and it should differ by package kind rather than be forced into one value:

Package kind module / moduleResolution Why
Published library nodenext / nodenext Enforces Node's real ESM rules, including explicit extensions
Bundled web app esnext / bundler Matches Vite/webpack resolution; extensionless imports allowed
Node service or CLI nodenext / nodenext Output runs directly in Node.js
Scripts run with type stripping nodenext + erasableSyntaxOnly Matches what Node can execute
Picking a preset for a new package A new package's kind decides whether it extends the library, bundler app or Node app preset. New workspace package who loads its output? library.json nodenext + declarations + composite npm consumers app-bundler.json bundler resolution, noEmit, DOM libs a bundler node-app.json nodenext, emits JavaScript Node.js directly
Decide how the package's output is consumed, and the preset follows.

Getting this wrong in one direction produces runtime errors such as those in Fixing ERR_MODULE_NOT_FOUND for Extensionless Imports; in the other, it produces needless compile errors in applications.

Editors, test runners and bundlers read the same files

A shared base only helps if every tool reads it. Editors use the nearest tsconfig.json to each open file, so a package without its own configuration falls back to the repository root's settings — often a solution file with no compiler options at all — and shows different errors from CI. Give every package a tsconfig.json, even a three-line one, so the editor and tsc always agree.

Vite, Vitest and esbuild read tsconfig.json for a small set of options — jsx, target-related settings, paths (with a plugin) and experimentalDecorators — and follow extends chains, including package-name bases. Jest with ts-jest does the same. The practical consequence is that decorators, JSX mode and path aliases belong in the shared presets rather than in tool-specific configuration, so that type-checking and transpilation cannot disagree.

Finally, remember that tsconfig.json files are inputs to caching task runners. Turborepo and Nx hash them when computing a task's cache key only if they are included in the task's inputs. If a base change does not invalidate your build cache, add the configuration package as a dependency of every package (as shown above) or add tsconfig*.json to the global inputs, as covered in Configuring Nx Named Inputs for Accurate Caching.

Worked example: removing drift from twelve packages

A repository with twelve packages has twelve hand-written configurations, no two identical. The migration starts by printing each package's effective configuration with tsc --showConfig and diffing them — the diff reveals three target values, two strict settings and four moduleResolution values. The team decides the intended settings per package kind, writes the base and three presets, and converts packages one at a time, fixing the type errors that stricter settings reveal. noUncheckedIndexedAccess alone surfaces around forty real bugs in array access code. After the migration, every package's own tsconfig.json is under ten lines, and a change to target is a one-line pull request that affects every package consistently.

Versioning the base without breaking everyone

Because every package extends the base, a tightened option is a repository-wide change. Turning on exactOptionalPropertyTypes or moving target forward can produce hundreds of errors at once. Two techniques keep such changes manageable. First, introduce the stricter option in a new preset (library-next.json) and migrate packages to it one at a time; when the last package moves, fold it back into the main preset. Second, for options that are purely additive checks, enable them in the base and add a temporary per-package override set to the old value in every package that fails, each with a tracking issue. Both approaches keep main green while the stricter setting spreads, and both leave a clear record of which packages still need work.

CLI validation and debug commands

# Print the fully merged configuration for one package
npx tsc -p packages/ui/tsconfig.json --showConfig

# Compare effective settings across packages
for p in packages/*/tsconfig.json apps/*/tsconfig.json; do
  echo "$p $(npx tsc -p $p --showConfig | jq -c '.compilerOptions | {module, moduleResolution, strict, target}')"
done

# Explain which files are included and why
npx tsc -p packages/ui/tsconfig.json --listFilesOnly | head
npx tsc -p packages/ui/tsconfig.json --explainFiles | grep -m5 "Matched by include"

Prevention and CI/CD guardrails

  • Keep path options out of shared files. outDir, rootDir, include and references belong to each package.
  • Type-check every package in CI from its own configuration, ideally with tsc -b over project references.
  • Review base changes like code changes. A change to @acme/tsconfig affects every package; run the full type-check on those pull requests rather than an affected-only subset.
  • Ban ad-hoc overrides of shared behaviour (such as strict: false) with a lint script over package configurations.

Frequently Asked Questions

Can I publish the shared config for other repositories? Yes — make the package public and version it. Consumers extend it by name exactly as workspace packages do. Community bases such as @tsconfig/node22 and @tsconfig/strictest follow this model.

Why does my include path in the base not apply? Because include, exclude and files are replaced, not merged, and relative paths in the base resolve from the base file's folder. Declare them in each package.

Should the root of the repository have a tsconfig.json? Keep one at the root only as a solution file listing references to every package for tsc -b and editor support, with "files": [] so it compiles nothing itself.

How do I opt one package out of a strict flag temporarily? Override the single option in that package's file, such as "noUncheckedIndexedAccess": false, and add a comment or tracking issue explaining when it will be removed. Keeping the override local and visible stops it from spreading into the shared base.

Related

Workspace Configuration Deep Dive