Blog Post|#engineering

Deep Dive Into Extending tsconfig.json

Cully Larson
Cully LarsonTuesday, February 27, 2024
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.


{ "compilerOptions": { "strict": true, "moduleResolution": "Node16", "module": "Node16" } }


{ "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?


{ "compilerOptions": { "strict": true, "moduleResolution": "Node16", "module": "Node16", "baseUrl": "./", "rootDir": "./src", "outDir": "dist" }, "exclude": ["./dist", "./node_modules"] }


{ "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:


{ "compilerOptions": { "strict": true, "allowJs": true, "moduleResolution": "Node16", "module": "Node16" }, "exclude": ["./dist", "./node_modules"] }


{ "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:


{ "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 Or hit our contact page.

Share this post


Related Posts:

Interested in working with us?

Give us some details about your project, and our team will be in touch with how we can help.

Get in Touch