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:
compilerOptionsmerge key by key; the extending file wins for any key it sets.files,includeandexcludedo 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.- Relative paths in
compilerOptions(outDir,rootDir,baseUrl,pathstargets) resolve relative to the file that declares them. A base that sets"outDir": "dist"points at adistfolder 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.
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.
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 |
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,includeandreferencesbelong to each package. - Type-check every package in CI from its own configuration, ideally with
tsc -bover project references. - Review base changes like code changes. A change to
@acme/tsconfigaffects 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 covers the workspace setup these configurations live in.
- TypeScript Project References in Monorepos builds incremental type-checking on top of the shared base.
- Setting Up Shared ESLint Configs in Workspaces applies the same pattern to lint configuration.
- Fixing Types Not Found Under node16 Module Resolution explains a common consequence of switching libraries to
nodenext.