Deep Dive Into Extending tsconfig.json
You have a monorepo with a client app, an API, and some shared packages. You want your typescript code to conform to the same settings throughout the project. The easiest way to do that is to create a tsconfig.json
file in the root project folder and then have each package extend it.
packages/tsconfig.json
{ "compilerOptions": { "strict": true, "moduleResolution": "Node16", "module": "Node16" } }
package/tools/tsconfig.json
{ "extends": "../tsconfig.json", "compilerOptions": { "baseUrl": "./", "rootDir": "./src", "outDir": "dist" } "exclude": ["./dist", "./node_modules"] }
This works great. However, you notice that all of your packages use the same baseUrl
, rootDir
, outDir
, and exclude
. So why not just put those in the base tsconfig.json
?
packages/tsconfig.json
{ "compilerOptions": { "strict": true, "moduleResolution": "Node16", "module": "Node16", "baseUrl": "./", "rootDir": "./src", "outDir": "dist" }, "exclude": ["./dist", "./node_modules"] }
package/tools/tsconfig.json
{ "extends": "../tsconfig.json" }
The problem is the paths in a tsconfig.json
are relative to the folder that that specific tsconfig.json
file is in, and not the tsconfig.json
doing the extending. So in this example, all the paths in packages/tsconfig.json
are relative to the packages/
folder, even when packages/tsconfig.json
is extended (i.e. extending doesn’t change the folder that paths are relative to). And it doesn’t matter if you put ./
in front of the path or not.
So, in our last example above, if we run cd packages/tools && tsc --showConfig
, we’ll get:
{ "compilerOptions": { "strict": true, "moduleResolution": "node16", "module": "node16", "baseUrl": "..", "rootDir": "../src", "outDir": "../dist" }, "files": [ "./src/tool.ts" ], "exclude": [ ".././dist", ".././node_modules" ] }
See how the paths all point to the parent folder? They are not relative to the packages/tools
folder. That means we always have to define baseUrl
, rootDir
, outDir
, include
, and exclude
in the child tsconfig.json
(the one that extends the base tsconfig.json
).
There’s more to exclude
That last sentence isn’t the whole story for exclude
. You can actually define exclude
in the base tsconfig.json
if you use wildcards. To illustrate this, let’s look at an example with allowJs: true
to highlight something:
packages/tsconfig.json
{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "Node16", "module": "Node16" }, "exclude": ["./dist", "./node_modules"] }
package/tools/tsconfig.json
{ "extends": "../tsconfig.json", "compilerOptions": { "baseUrl": "./", "rootDir": "./", "outDir": "dist" } }
Build the tools
project (tsc --build
), and then run tsc --showConfig
:
{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "node16", "module": "node16", "baseUrl": "./", "rootDir": "./src", "outDir": "./dist" }, "files": [ "./dist/src/tool.js", "./src/tool.ts" ], "exclude": [ "../dist", "../node_modules" ] }
See that files
includes dist/src/tool.js
? That file is not being excluded. However, if we use wildcards in the exclude
property in the base config:
packages/tsconfig.json
{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "Node16", "module": "Node16" }, "exclude": ["**/dist", "**/node_modules"] }
And run tsc --showConfig
again:
{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "node16", "module": "node16", "baseUrl": "./", "rootDir": "./src", "outDir": "./dist" }, "files": [ "./src/tool.ts" ], "exclude": [ "../**/dist", "../**/node_modules" ] }
Notice that dist/src/tool.js
is no longer included. This is because the wildcard **/dist
matches packages/tools/dist
too, even though it’s relative to the packages/
folder.
That means you can define exclude
in the base config and have it affect the extending packages. You just have to use wildcards. You might still opt not to define exclude
in the base tsconfig.json
and force each package to define its own since will avoid potential issues if someone forgets a wildcard or a package has other folders that need to be excluded.
On a side note, you could use a single star (e.g. */dist
) too and it will work the same way.
What about include
?
include
works the same way as exclude
. However, the wildcard solution does not work in the base tsconfig.json
. You’d think it wouldn’t work because it will match all the files in any folder named src
in the project, but it’s weirder than that. For example, let’s say you have another project in packages/api
with a file named src/handler.ts
. If you then run cd packages/client && tsc --showConfig
, you’ll get:
{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "node16", "module": "node16", "baseUrl": "./", "rootDir": "./src", "outDir": "./dist" }, "files": [ "../api/src/handler.ts" ], "include": [ "../**/src" ], "exclude": [ "dist" ] }
Your client/src/tool.ts
file isn’t even in the list. That’s because tsc
matches the first folder it finds named src
and doesn’t keep looking. The packages/api/src
folder is searched first and tsc
doesn’t continue on to look at packages/client/src
.
So you will have to define include
in each of the child tsconfig.json
files.
Some other things
It’s worth noting that when you extend a tsconfig.json
, the properties in compilerOptions
are merged. And when both files define the same property, the child tsconfig.json
wins. However, this is not the case with include
and exclude
. If you define them in the child tsconfig.json
, that exact value will be used; it won’t be merged with the value from the base tsconfig.json
.
We used a monorepo for these examples, but you could run into this situation in a single-project repo as well. For example, if you want to override tsconfig.json
in a tests/
folder or a folder with Storybook stories.
Cully is a senior software engineer who you can follow on X here. Feel free to reach out to us for any and all of your technology-building needs at hi@echobind.com. Or hit our contact page.