tsconfig.json Explained
The tsconfig.json file decides what "TypeScript says it is fine" actually means in your project. It sets which files are checked, how suspicious the checker is, how imports resolve, and what JavaScript comes out. Two projects with the same code and different configs can disagree about whether that code compiles.
This guide covers what the file controls, what strict really enables, the flags worth turning on deliberately, and how to split a config across a workspace without copying it.
What the File Controls
A working starting point for an application, with the parts grouped by the job they do:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
} Three groups are doing different work here, and mixing them up is the usual source of copied-config trouble. strict and the checks beneath it are the type-checking posture, and they travel well between projects. target, module, and moduleResolution describe the runtime and bundler, so they do not. include and exclude decide the program's file set.
noEmit is worth calling out: it says this config only checks types and writes no JavaScript, which is the normal arrangement when a bundler or framework handles the build. Leave it off for a package that tsc itself compiles.
Which Files Get Checked
The compiler starts from include (or files), drops whatever exclude matches, then follows every import out from what remains. exclude filters the include results only, so a file named explicitly in files stays in the program. The import-following step is what trips people up, because it means exclude is not a firewall:
{
"include": ["src"],
"exclude": ["src/legacy"]
} If src/app.ts imports src/legacy/parser.ts, the excluded file is still added to the program and still checked, because the compiler cannot type the importer without it. exclude only removes files from the initial discovery pass.
Configuration is also per directory, not global. Running tsc without a file argument finds the nearest tsconfig.json by walking up from the current directory. Since TypeScript 6, passing filenames on the command line while a tsconfig.json is present is an error, and tsc --ignoreConfig src/app.ts is how you ask for the old defaults-only behaviour, which is why such a command can report completely different errors from the CI failure you were reproducing.
What Strict Mode Turns On
"strict": true is a switch for a family of checks rather than a single rule. The members most likely to change how you write code:
noImplicitAnyreports parameters and variables the compiler cannot infer, instead of falling back toany.strictNullCheckskeepsnullandundefinedout of other types unless declared, so each becomes a distinct type you narrow before use.strictFunctionTypeschecks function-typed parameters contravariantly, catching some unsound callback assignments.strictPropertyInitializationrequires class fields to be assigned in the constructor or by an initializer, unless the type includesundefinedor carries a definite-assignment assertion.strictBindCallApplytypesbind,call, andapplyagainst the real signature.useUnknownInCatchVariablestypes a caught error asunknownrather thanany, so you narrow before using it.
Each can be set individually, which is what makes a gradual migration possible: turn strict on and switch one member back off while its errors are worked through, rather than leaving the whole family disabled. Note that the exact membership of the group has grown across releases, so on an older compiler version some of these are separate opt-ins.
Flags You Add Deliberately
Several of the most useful checks are not part of strict and stay off until you name them:
const tags = ["typescript", "types"];
const first = tags[0];
const third = tags[2];
console.log(third.toUpperCase()); // with noUncheckedIndexedAccess enabled, tsc reports:
// error TS18048: 'third' is possibly 'undefined'. Without the flag, tags[2] is typed string even though the array has two elements, and the mistake reaches runtime as a TypeError. With it, unchecked array and index-signature lookups yield Type | undefined, while declared properties and statically valid tuple positions stay definite. That is more honest and noticeably more work in lookup-heavy code.
exactOptionalPropertyTypes is the other one worth knowing by name. It separates "the property is absent" from "the property is present and set to undefined", so an optional field no longer silently accepts an explicit undefined. That distinction matters most for patch-style updates, where the two cases mean different things.
Beyond those, noFallthroughCasesInSwitch, noImplicitOverride, and noImplicitReturns each catch a specific class of mistake at low cost. There is no need to adopt everything at once: add one, fix what it reports, and keep it.
Module, Target, and Emit Settings
These describe the world the code runs in, so they are the settings least safe to copy between projects. target sets the JavaScript version the compiler emits and, with it, which library types are available by default. module sets the module format of the output, and moduleResolution tells the compiler how to find an import specifier.
The pairing matters more than either value alone: "moduleResolution": "Bundler" is intended for code processed by a bundler and expects a modern module setting such as ESNext or Preserve, while a Node package resolving its own imports wants a Node-oriented mode instead. A mismatch usually shows up as an import that resolves in the editor and fails in the build, or the reverse.
Two smaller options save real time. skipLibCheck stops the compiler type checking inside declaration files, which most application projects enable. isolatedModules is the general check that every file can be transpiled on its own, while verbatimModuleSyntax controls import and export elision and rejects module syntax that would need incompatible rewriting. Between them they keep a fast build tool and a full type check agreeing with each other.
Extending and Splitting Configs
In a workspace, put the shared half in one file and let each package extend it:
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
}
}
// packages/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist"
},
"include": ["src"]
} The package config inherits the strictness posture and sets only what is genuinely local. Options given in the extending file replace the inherited value rather than merging with it, and relative paths in an inherited config resolve from the file that declared them, which is the usual explanation for an outDir landing somewhere unexpected.
For larger workspaces, project references (references plus composite) let tsc --build check packages in dependency order and reuse previous results. They add setup cost, so they pay off once builds are slow enough to notice rather than on day one.
Pitfalls and Debugging
The editor and the build disagree. Usually two different configs or two different compiler versions. Editors typically use the nearest config to the open file, and may run their own bundled TypeScript unless told to use the workspace version. Confirm both before hunting for a compiler bug.
A copied config that does not match the runtime. The strictness half of someone else's config is usually safe to adopt; the module and target half describes their bundler and runtime. Copying both is how a Node service ends up with browser-oriented resolution.
Assuming the config runs anywhere. tsconfig.json configures type checking and tsc. A bundler that strips types with esbuild or SWC reads only some of it, and enforces none of the checking, which the workflow guide covers.
Turning strict on across a large codebase at once. Thousands of errors nobody triages tends to end with the flag being reverted. Enable it, disable one member such as strictNullChecks temporarily, and re-enable it once its errors are worked through. Flags apply to a whole configured program, so staging by directory needs separate configs or project references.
Frequently Asked Questions
Does strict true turn on every strictness check?
No. It enables the family of flags grouped under the strict umbrella, including noImplicitAny, strictNullChecks, strictFunctionTypes, and strictPropertyInitialization. Several stricter options sit outside that group and stay off unless you name them, notably noUncheckedIndexedAccess and exactOptionalPropertyTypes.
Why is a file being checked when exclude lists it?
Because exclude only filters the initial file discovery. If any included file imports the excluded one, it is pulled into the program anyway, since the compiler cannot check the importer without it. Stopping that means removing the import or splitting the code into a separate project.
Should you enable skipLibCheck?
Most application projects do, and the trade is worth understanding. It skips type checking inside declaration files, which cuts build time and stops errors in third-party types from failing your build. The cost is that a genuine conflict between two library versions can go unreported, so library authors are more cautious about it.
Can one tsconfig serve a whole monorepo?
It can, but a shared base plus one config per package usually ages better, because packages differ in module settings, JSX, and target even when their strictness matches. The extends field keeps the shared half in one file, and project references let the compiler build packages in dependency order.
Related
- TypeScript Tooling for the full topic overview
- Tooling and Project Workflow for running the checker and wiring it into CI
- TypeScript with React for the JSX settings this file controls
- TypeScript for the language guide and the full learning path
Sources
-
[1]
TSConfig Reference(typescriptlang.org)
-
[2]
What is a tsconfig.json(typescriptlang.org)
-
[3]
Project References(typescriptlang.org)
Read Next
The compiler commands worth knowing, why transpiling is not type checking, what the editor language service does, wiring a real check into CI, and migrating a JavaScript codebase.
JSX settings, component prop types, children and native element props, typed state and refs, event handlers, and what types cannot enforce across the server and client boundary.
The half of TypeScript that is not types: tsconfig.json, the split between transpiling and type checking, React components, and API contracts between a frontend and its server.