TypeScript tsconfig.json
Every real TypeScript project has a file called tsconfig.json sitting at its root. It tells the TypeScript compiler (tsc) two things: which files belong to your project, and exactly how strictly and to what JavaScript those files should be checked and compiled. Get it wrong and you either miss real bugs, ship code that breaks on your target runtime, or fight confusing errors about files that “shouldn’t” be part of your project. This lesson covers the file in full: its structure, the options that matter most, how the compiler actually resolves it, and the mistakes almost everyone makes at least once.
Overview: How tsconfig.json Works
When you run tsc with no arguments, it doesn’t compile a single file — it looks in the current directory, and then upward through parent directories, for a file named tsconfig.json. The directory containing that file becomes the root of the TypeScript project. Once found, tsconfig.json answers two separate questions for the compiler:
- Which files are in scope? Controlled by
include,exclude, andfiles. - How should they be checked and emitted? Controlled by the
compilerOptionsobject — things like how strict the type checker is, which JavaScript version to target, and where output goes.
A useful mental model: tsconfig.json never changes what your code does at runtime. It only changes what the type checker is willing to accept, and what JavaScript syntax the compiler is allowed to emit. Type annotations, interfaces, and generics are all erased during emit regardless of your configuration — tsconfig.json just decides how picky the compiler is before that erasure happens, and what dialect of JavaScript comes out the other end.
One detail that surprises people coming from strict JSON backgrounds: tsconfig.json is not parsed as strict JSON. TypeScript uses a lenient, JSON5-like parser for it, which is why you’ll commonly see // comments and trailing commas in real-world tsconfig.json files even though a plain JSON.parse call would reject them.
Syntax
The general shape of a tsconfig.json file looks like this:
{
"extends": "./base.tsconfig.json",
"compilerOptions": {
// type-checking and emit settings go here
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"files": ["src/main.ts"],
"references": [{ "path": "../shared" }]
}
| Top-level key | Purpose |
|---|---|
extends |
Inherits settings from another config file, so multiple packages can share one base configuration. |
compilerOptions |
The bulk of the file — controls type-checking strictness, target JS version, module system, output location, and more. |
include |
Glob patterns for files to add to the project (e.g. "src/**/*"). |
exclude |
Glob patterns to remove from what include matched. Defaults to ["node_modules", "bower_components", "jspm_packages"] plus the outDir. |
files |
An explicit list of individual file paths, useful for small projects with no globbing. |
references |
Points to other TypeScript projects for project references / composite, incremental builds. |
Inside compilerOptions, the options you’ll reach for constantly are:
| Option | What it does |
|---|---|
target |
The ECMAScript version to compile down to (e.g. "ES2020"). Newer syntax is down-leveled if the target is older. |
module |
The module system to emit ("commonjs", "ESNext", "NodeNext", etc.). |
lib |
Which built-in type declarations are available to the checker (e.g. "DOM", "ES2020"). Independent of target. |
strict |
Enables the full family of strict type-checking flags (see below) in one switch. |
outDir / rootDir |
Where compiled output goes, and which folder is treated as the source root. |
esModuleInterop |
Allows default-import syntax for CommonJS modules that don’t have a real default export. |
skipLibCheck |
Skips type-checking of .d.ts files (mainly in node_modules), speeding up builds. |
declaration |
Emits matching .d.ts files alongside the compiled JavaScript. |
baseUrl / paths |
Configure non-relative module resolution and import aliases. |
Examples
Example 1: A standard project layout
A typical Node-targeted project keeps sources in src/ and compiles to dist/:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
interface User {
id: number;
name: string;
}
function greet(user: User): string {
return `Hello, ${user.name}! Your ID is ${user.id}.`;
}
const user: User = { id: 1, name: "Ava" };
console.log(greet(user));
Output:
Hello, Ava! Your ID is 1.
Nothing about this output depends on tsconfig.json — but the fact that it compiled cleanly does. include tells tsc to pick up every file under src, rootDir/outDir keep source and build output separated, and strict is what would have caught it if user were missing a required field.
Example 2: strict mode changing what the checker accepts
With "strict": true, strictNullChecks is turned on, which means anything that can be undefined — like the result of Array.prototype.find — must be handled before use:
function findUser(id: number, users: { id: number; name: string }[]): string {
const found = users.find(u => u.id === id);
if (!found) {
return "Unknown user";
}
return found.name;
}
const users = [
{ id: 1, name: "Ava" },
{ id: 2, name: "Ben" }
];
console.log(findUser(2, users));
console.log(findUser(5, users));
Output:
Ben
Unknown user
Without strictNullChecks, found.name would type-check even if you forgot the if (!found) guard, and it would crash at runtime with “Cannot read properties of undefined” the first time a lookup failed. The tsconfig.json setting is what forces you to handle that case at compile time instead.
Example 3: target and lib enabling modern syntax
Optional chaining (?.) and nullish coalescing (??) are ES2020 features. They need a target/lib combination that recognizes ES2020 syntax and globals:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"strict": true
}
}
interface Config {
timeout?: number;
retries?: {
max?: number;
};
}
function getMaxRetries(config: Config): number {
return config.retries?.max ?? 3;
}
console.log(getMaxRetries({}));
console.log(getMaxRetries({ retries: { max: 5 } }));
Output:
3
5
config.retries?.max short-circuits to undefined if retries is missing, and ?? 3 supplies the fallback only when the left side is null or undefined (unlike ||, which would also replace a valid 0). target controls what syntax is allowed to appear in the emitted JavaScript; lib separately controls which global APIs the type checker knows about.
Under the Hood: How tsc Resolves Your Config
When tsc starts, it performs a few distinct steps before checking a single line of your code:
- Discovery. Starting from the current directory (or the path passed to
--project),tscwalks upward looking fortsconfig.json. The first one found wins. - Extends resolution. If
extendsis present, the parent config is loaded first, then the child’scompilerOptionsare shallow-merged on top of it. Crucially,include,exclude, andfilesare not merged — if the child defines its own, it fully replaces the parent’s rather than adding to it. This trips up a lot of monorepo setups. - File enumeration.
includeglobs are expanded, then anything matchingexcludeis removed, then anything listed infilesis added explicitly regardless of the other two. - Flag expansion. Shorthand flags are expanded into their real effects.
"strict": true, for example, is not one flag — it flips onnoImplicitAny,strictNullChecks,strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,noImplicitThis,alwaysStrict, anduseUnknownInCatchVariablestogether. You can still override any one of them individually below"strict": true. - Type-checking. The checker walks the program using the effective flags — this is purely compile-time analysis based on inferred and declared types; it never runs your code.
- Emit. If
noEmitisn’t set, JavaScript is written out according totarget(syntax level) andmodule(module system), with all type annotations, interfaces, and generic parameters stripped completely. The emitted.jsfile carries zero trace of the type system —tsconfig.jsononly ever influenced how that JavaScript was validated and shaped before the types were thrown away.
Common Mistakes
Mistake 1: Skipping strict mode and losing implicit-any safety
Without strict, TypeScript happily accepts untyped parameters as any, silently disabling most of its own value:
// tsconfig.json — strict mode missing
{
"compilerOptions": {
"target": "ES2020"
}
}
function double(x) {
return x * 2;
}
console.log(double("4"));
This compiles fine and prints NaN at runtime because x is implicitly typed any, so a string sneaks past the multiplication. Turn on strict and tsc immediately reports: Parameter 'x' implicitly has an 'any' type. The fix is to enable strict mode and give the parameter a real type:
function double(x: number): number {
return x * 2;
}
console.log(double(4));
Output:
8
Mistake 2: Using DOM globals without the “DOM” lib
If lib is set explicitly and "DOM" is left out, browser globals disappear from the type checker’s vocabulary:
// tsconfig.json — DOM lib missing
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"]
}
}
const el = document.querySelector("#app");
console.log(el?.textContent);
This fails with Cannot find name 'document'. — not because document doesn’t exist in a browser, but because the checker was never told about it. Adding "DOM" back to lib fixes it:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"]
}
}
const el = document.querySelector("#app");
console.log(el?.textContent ?? "no element found");
Output:
no element found
Note that lib only affects what the checker knows exists — it does not add or remove any actual runtime capability, and this snippet is only meant to be type-checked, not executed in Node (there is no real document there).
Best Practices
- Turn on
"strict": trueon every new project — it’s far harder to retrofit onto a large existing codebase later. - Use
"skipLibCheck": trueso type-checking third-party.d.tsfiles innode_modulesdoesn’t slow down every build. - Keep
rootDirandoutDirdistinct so compiled output never mixes with source files or gets accidentally re-included. - Scope
include/excludedeliberately rather than relying on defaults, especially in monorepos with multiple packages. - Remember that
extendsmergescompilerOptionsbut replacesinclude/exclude/files— repeat what you need in the child config. - Match
targetandlibto the runtime you actually deploy to, not the newest possible option. - Enable
"forceConsistentCasingInFileNames": trueto catch import-casing bugs before they break a Linux CI server. - For large multi-package repositories, use
"composite": truewithreferencesfor faster, incremental project builds.
Practice Exercises
- Write a
tsconfig.jsonfor a Node 18 project: CommonJS modules, output todistfrom asrcroot, strict mode on, and source maps enabled. Then write a tiny function with a missing parameter type and note the exacttscerror it produces before you fix it. - Create a base config with
"include": ["src/**/*"]and a child config thatextendsit while also declaring its own"include": ["test/**/*"]. Predict which folders end up in the compiled project, then check whether your prediction matches howextendsactually merges (or doesn’t merge) array options. - Enable
"noUnusedParameters": truein a config, then write a function with a parameter it never uses. Note the exact errortscreports, then fix it two different ways: removing the parameter, and prefixing it with an underscore.
Summary
tsconfig.jsonmarks a project root and controls both which files are compiled and how strictly they’re checked.include/exclude/filesdecide file scope;compilerOptionsdecides checking behavior and emit shape."strict": trueis shorthand for several individual flags likenoImplicitAnyandstrictNullChecks— enable it from day one.targetcontrols the emitted JavaScript syntax level;libseparately controls which global APIs the type checker recognizes.extendsshares a base config across projects, but mergescompilerOptionsonly — array options likeincludeare replaced, not combined.- All of this is compile-time only: types, interfaces, and generics are fully erased at emit, leaving plain JavaScript with no runtime trace of the configuration that produced it.
