35.6 Shared tsconfig Packages: Extending a Base Config
Right, so you’ve decided to organize your monorepo like a sane person. Good for you. But now you’re staring down ten different packages/ directories, each with its own tsconfig.json, and you’re about to copy-paste the same 20 lines of configuration for the ninth time. Stop. We’re not animals. We’re going to create a single, shared tsconfig package that everything else can extend. It’s the closest thing to a “set it and forget it” miracle you’ll get in this line of work.
The core idea is beautifully simple: a tsconfig.json file can use the extends property to inherit from another configuration file. And since npm (or pnpm, or yarn) can install packages that are just JSON files, we can publish a package whose sole purpose is to be this base configuration.
The Anatomy of a Shared tsconfig Package
First, create a new package within your monorepo. I usually call it something painfully obvious like @mycompany/tsconfig or @mycompany/config-typescript.
packages/
tsconfig-base/
package.json
base.json
react-library.json
node16.json
my-awesome-app/
my-shared-lib/
The package.json for this config package is dead simple. Its main job is to be installed. The files array or exports field ensures our JSON files are included when we npm publish or when our package manager symlinks it within the monorepo.
// packages/tsconfig-base/package.json
{
"name": "@mycompany/tsconfig-base",
"version": "1.0.0",
"description": "Base TypeScript configuration for our monorepo",
"files": ["*.json"],
"keywords": ["typescript", "config"],
"author": "You (you brilliant developer, you)",
"license": "MIT"
}
Now, the star of the show: the config file itself. Let’s create base.json. This is where you put all the universal settings that every single package in your repo should use. The goal is maximum strictness and consistency.
// packages/tsconfig-base/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
// This is non-negotiable. Strict mode is the whole point of TS.
"strict": true,
// Modern project, modern module system.
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
// Absolute clarity on what's allowed. No `any` sneaking through.
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
// Because checking unused vars is just good hygiene.
"noUnusedLocals": true,
"noUnusedParameters": true,
// This one is a matter of taste, but I prefer explicit returns.
"noImplicitReturns": true,
// Crucial for monorepos: map imports back to source for debugging.
"sourceMap": true,
// How we'll handle module paths
"baseUrl": ".",
"paths": {
"@mycompany/*": ["./packages/*/src"]
}
}
}
Consuming Your Shared Config in Other Packages
Here’s the payoff. In any other package in your monorepo, you first add your config package as a dependency: npm install -D @mycompany/tsconfig-base. Then, in that package’s tsconfig.json, you simply extend it.
// packages/my-awesome-app/tsconfig.json
{
"extends": "@mycompany/tsconfig-base/base.json",
"compilerOptions": {
// You can override anything from the base here.
// The base target is ES2022, but this app needs older browser support?
"target": "ES2017",
// This app-specific path
"paths": {
"@app/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
See what happened there? The extends property is resolved by TypeScript using Node module resolution. It literally goes into your node_modules, finds the @mycompany/tsconfig-base package, and pulls in base.json. Everything in that file becomes the foundation, and your local tsconfig.json only needs to specify the differences. It’s inheritance, but for configs. It’s beautiful.
The Inevitable “But What About React?!” Question
Your backend Node service and your frontend React app, while both beloved children, have different needs. The base config is great, but it’s not one-size-fits-all. This is why we created multiple files in our config package, like react-library.json.
The React config can extend the base and then add React-specific settings.
// packages/tsconfig-base/react-library.json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
// Allow default imports, because that's the ecosystem.
"allowSyntheticDefaultImports": true
}
}
Now a React component library can extend the React-specific config, keeping its own config file incredibly clean.
// packages/my-shared-lib/tsconfig.json
{
"extends": "@mycompany/tsconfig-base/react-library.json",
"include": ["src/**/*"]
}
The Gotchas: Because Nothing Is Ever Easy
The
baseUrlTrap: Notice the"baseUrl": "."in the base config? This is a problem. The"."resolves to the directory containing the config file—which ispackages/tsconfig-base/, not the package that extends it. This will break yourpathsmapping spectacularly. Solution: You must redefinebaseUrlandpathsin every finaltsconfig.jsonthat uses them. Annoying? Yes. Unavoidable? Also yes. Consider using a tool liketsconfig-pathsor your bundler (Vite, Webpack) to handle path mapping instead, and leave it out of the basetsconfigentirely.IDE/Editor Support: Most modern editors (VSCode, WebStorm) will pick up the extended config without a hitch. But if you ever see it complaining that it can’t resolve
@mycompany/tsconfig-base, try restarting the TypeScript language server (in VSCode, the command is> TypeScript: Restart TS Server). It usually just needs a kick.Publishing: If you’re actually publishing packages to a registry, remember this config package is a development dependency. It gets stripped out for your end-users, which is exactly what you want. They don’t need to know how you lint your code.
The beauty of this system is that when the architecture board decides we’re moving to "target": "ES2030" and enabling a new flag called "noSillyCode", you change it in one place. You version-bump your @mycompany/tsconfig-base package, update the dependency in all your other packages, and you’re done. It turns a sprawling, error-prone maintenance task into a simple, version-controlled update. And that, my friend, is how you keep your sanity in a monorepo.