Skip to content

tsconfig, Deep

For a decade, an empty tsconfig.json meant a lax compiler: no strict, CommonJS output, an old ES target, and every @types/* package in node_modules silently loaded. TypeScript 6.0 (March 2026) flipped the defaults to what almost every project was already writing by hand, and TypeScript 7.0 (July 2026, the native compiler) made them permanent:

OptionBefore 6.06.0 / 7.0 default
strictfalsetrue
modulecommonjsesnext
targetes2020-ishes2025 — floats to the current year’s ECMAScript
typesevery @types/* in node_modules[] — nothing unless you list it
rootDirinferred from your sources. — the directory of tsconfig.json
noUncheckedSideEffectImportsfalsetrue
libReplacementtruefalse

Two of these bite real projects on upgrade. types: [] means @types/node is no longer picked up just because it is installed — write "types": ["node"] (or ["*"] to restore the old behavior). rootDir: "." means a src/ layout emits into dist/src/ unless you set "rootDir": "./src" explicitly — and this is not silent: with outDir set but rootDir left at its default, tsc stops with a hard error, and still writes the file to the nested path:

error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must
be explicitly set to this or another path to adjust your output's file layout.

tsc --init generates a short, opinionated file instead of a wall of commented-out options. This is the real output of TypeScript 7.0.2’s tsc --init (comments trimmed, but the commented-out rootDir/outDir lines are exactly as generated):

{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}

Notice rootDir and outDir are generated commented out — tsc --init shows you the shape but does not guess your layout, which is exactly the rootDir gotcha covered below. verbatimModuleSyntax and isolatedModules are covered in Build & Emit; several other lines here are now redundant with the defaults (strict, noUncheckedSideEffectImports) — the generator keeps them explicit so the file documents itself.

Which files are in the program: include, exclude, files, extends

Section titled “Which files are in the program: include, exclude, files, extends”

Before any option changes checking behavior, tsc has to decide which files it’s checking at all — that’s a separate, earlier question, controlled by four keys:

  • include — glob patterns describing the program’s files. "src/**/*" means every file under src.
  • exclude — glob patterns removed from what include matched (it does not add files back that include never found). node_modules, .git, and the outDir are excluded by default even with no exclude key.
  • files — an explicit list of individual files, always included regardless of include/exclude — the one way to force a specific file into the program.
  • extends — inherits another config’s compilerOptions (and, since 3.9, its include/exclude/files too, unless the extending file overrides them), so a monorepo can share one base config across packages.

Here is a real small project — a base config plus one that extends it, with include, exclude, files, and paths all in play — and the exact tsc --showConfig output for it (verified in scratch):

tsconfig.base.json
{
"compilerOptions": {
"target": "es2025",
"module": "nodenext",
"strict": true
}
}
tsconfig.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"paths": { "@utils/*": ["./src/utils/*"] }
},
"include": ["src/**/*"],
"exclude": ["src/**/*.spec.ts"],
"files": ["src/extra-entry.ts"]
}

tsc --showConfig resolves the extends chain, merges every option into one flat object, and lists the exact files the program ends up with:

{
"compilerOptions": {
"module": "nodenext",
"outDir": "./dist",
"paths": { "@utils/*": ["./src/utils/*"] },
"rootDir": "./src",
"strict": true,
"target": "es2025",
"moduleResolution": "nodenext",
"moduleDetection": "force"
},
"files": [
"./src/extra-entry.ts",
"./src/extra-entry.ts",
"./src/index.ts",
"./src/utils/math.ts"
],
"include": ["src/**/*"],
"exclude": ["src/**/*.spec.ts"]
}

Two details worth noticing in that real output: moduleResolution and moduleDetection appear even though neither config wrote them — tsc fills in the values module: nodenext implies. And src/extra-entry.ts is listed twice — once because files named it explicitly, once because include’s glob also matched it. tsc doesn’t deduplicate the two sources against each other; a file named in both simply appears twice in the resolved list, harmlessly (it’s still only checked once), but it’s a sign the files entry was redundant with include here.

paths is a type-only remap — it does nothing at runtime

Section titled “paths is a type-only remap — it does nothing at runtime”

paths (used above: "@utils/*": ["./src/utils/*"]) tells the checker where to look for a module specifier — nothing more. It has no effect on how your code is actually loaded, and running the compiled output proves it. Compiling the project above (tsc, real emit) and running the result directly with Node:

$ node dist/index.js
node:internal/modules/cjs/loader:1421
throw err;
Error: Cannot find module '@utils/math'
Require stack:
- .../dist/index.js
code: 'MODULE_NOT_FOUND'

tsc type-checked import { add } from "@utils/math" successfully — paths told it exactly where @utils/math lives for checking purposes — but it emits that import specifier unchanged, and Node has never heard of @utils/*. A bundler that reads the same paths entry (Vite, webpack) rewrites the specifier at bundle time, which is why paths “just works” in a bundler-based project and silently breaks in a tsc-only one. If tsc is your only build step, either avoid paths for anything that ships, or replace it at runtime yourself — Node’s own imports field in package.json (the #/* mapping from Modules & Resolution) is the one alias mechanism both tsc and Node understand.

tsc --showConfig: what the compiler thinks your config means

Section titled “tsc --showConfig: what the compiler thinks your config means”

--showConfig (used above) is the fastest way to answer “wait, what did extends actually resolve to?” without reading through a chain of files by hand — it never compiles anything, it just prints the fully-merged configuration tsc would use. Reach for it whenever a project inherits from a shared base config and a flag isn’t behaving the way you expect: the merged JSON tells you immediately whether the value you think is set actually survived the inheritance chain.

Reading the compiler’s own performance numbers

Section titled “Reading the compiler’s own performance numbers”

Two flags make tsc report on itself instead of (or in addition to) your code. --extendedDiagnostics prints a summary after checking — real output for a small program with a handful of conditional and mapped types (verified in scratch):

Files: 83
Lines: 58450
Identifiers: 49750
Symbols: 59787
Types: 35364
Instantiations: 34403
Memory used: 64885K
Parse time: 0.013s
Bind time: 0.005s
Check time: 0.107s
Emit time: 0.000s
Total time: 0.125s

Instantiations is the number this course cares about most: every time a generic type or function gets a concrete type argument substituted in, that’s one instantiation, and it’s the number that explodes on a runaway recursive conditional type long before “Types” or “Symbols” look unusual — a program with a healthy Files/Types ratio but a huge Instantiations count is a real signal to go looking for a type-level computation that’s re-deriving the same thing over and over. Check time isolates the type-checking phase specifically, separate from parsing and emitting, so it’s the number to compare before/after a tsconfig change (a stricter flag, a new lib) to see what it actually cost.

--generateTrace <dir> goes further, writing a Chrome DevTools-compatible trace.json (open it in chrome://tracing or the Perfetto UI) plus one or more types_N.json files mapping every type ID mentioned in the trace back to its structure. The trace is an event timeline — hundreds of entries like {"ph":"X","name":"checkSourceFile","dur":...} — that lets you see which file or which type the checker spent its time on, rather than only the program-wide total --extendedDiagnostics gives you. It’s the right tool once --extendedDiagnostics has told you check time is high and you need to find where, not just that.

useUnknownInCatchVariables, part of strict, checked live

// @expect-error TS18046
function parseNumber(input: string): number {
try {
return JSON.parse(input);
} catch (e) {
console.log(e.message);
return 0;
}
}
Open in TS Playground

This is useUnknownInCatchVariables from the strict table below, live: strict is always on in this checker, so e is typed unknown, and reading .message off it without narrowing first is a real error — a plain catch (e) {} in JavaScript gives you no guarantee e is even an Error, and the type reflects that honestly instead of guessing.

strict is the default now, but it is still worth knowing what it bundles, because you will meet codebases that turned parts of it off:

Flag (inside strict)What it catches
strictNullChecksnull and undefined are no longer silently in every type — the biggest win
noImplicitAnyA value that would fall back to any becomes an error you must resolve
strictFunctionTypesFunction parameters are checked contravariantly — unsafe callbacks are caught
strictBindCallApplybind, call, and apply are type-checked against the function signature
strictPropertyInitializationClass fields must be assigned in the constructor (or marked optional)
useUnknownInCatchVariablescatch (e) gives you unknown, not any, forcing you to narrow
strictBuiltinIteratorReturnBuilt-in iterators return undefined when done, not any (5.6)
noImplicitThisA this that would fall back to any (e.g. in a plain function used as a method) becomes an error
alwaysStrictParses in strict mode and emits "use strict" — can no longer be turned off

The headline is strictNullChecks. Without it, string secretly includes null and undefined, and the compiler cannot warn you about the single most common runtime crash in JavaScript. With it, null and undefined are their own types that you must handle deliberately.

If you inherit a project with "strict": false, treat it as a migration task (see Migration Strategy) — flip the flags on one at a time rather than deleting the line and drowning in errors.

Three options describe the world your code runs in:

  • target — which JavaScript version tsc emits. The default es2025 (and esnext) means no down-leveling: modern syntax is passed through, which is what every current runtime wants. Lower targets still down-level newer syntax — but es5 is gone (a hard error in 7.0; ES2015 is the floor), and with it downlevelIteration.
  • module — the module format of the output. nodenext when Node runs the output directly, esnext or preserve when a bundler does, and the stable node18 / node20 when you want behavior pinned to a specific Node line. The next lesson covers these in depth.
  • lib — which built-in type declarations are available. lib.dom now includes what used to live in dom.iterable and dom.asynciterable; es2025 adds RegExp.escape, the Iterator helper methods, the Set algebra methods and the Map upsert methods (getOrInsert, getOrInsertComputed), and the Temporal types ship with 6.0. If you use an API and get “does not exist”, your lib is too old.

strict is the floor, not the ceiling. Two more flags catch bugs that strict alone misses:

// noUncheckedIndexedAccess: true
const arr = [1, 2, 3];
const x = arr[10]; // type is now `number | undefined`, not `number`
x.toFixed(); // ❌ Object is possibly 'undefined' — exactly the bug you want caught
  • noUncheckedIndexedAccess — indexing an array or record can return undefined (index 10 of a 3-element array does), so the type reflects that. Enormously effective against out-of-bounds bugs.
  • exactOptionalPropertyTypes — distinguishes a property set to undefined from a property that is absent, so an optional name?: string no longer silently accepts name: undefined.
  • noUncheckedSideEffectImports (default since 6.0) — import "./styles.css" used to resolve to nothing silently if the path was wrong; now a typo is an error. Declare declare module "*.css" {} once for asset imports your bundler handles.
  • erasableSyntaxOnly (5.8) — forbids the TypeScript syntax that needs a transform rather than a delete: enum, namespace with runtime code, class parameter properties, import x = require(). Turn it on if you run .ts files directly in Node (type stripping), Deno, or Bun, where the runtime only strips types.

Options that were removed, and what replaces them

Section titled “Options that were removed, and what replaces them”

TypeScript 6.0 deprecated a set of options; 7.0 removed them. In 6.x you can buy time with "ignoreDeprecations": "6.0", but each has a direct replacement:

RemovedReplace with
baseUrlfold the prefix into paths: "@app/*": ["./src/app/*"]
moduleResolution: node / node10 / classicnodenext (Node runs it) or bundler (a bundler does)
module: amd / umd / system / noneesnext or preserve, and let a bundler produce other formats
outFilea bundler
target: es5, downlevelIterationes2015 or newer
esModuleInterop: false, allowSyntheticDefaultImports: false, alwaysStrict: falsenothing — these are always on
import x from "./data.json" asserts { type: "json" }with { type: "json" }
module Foo {} (legacy namespace keyword)namespace Foo {}
/// <reference no-default-lib="true" />noLib or libReplacement

One CLI change belongs here too: running tsc file.ts in a directory that has a tsconfig.json is now an error (TS5112), because it silently ignored the config for years. Pass --ignoreConfig if you really mean it.

  • ${configDir} (5.5) — a template variable for paths in a shared base config, so "outDir": "${configDir}/dist" resolves relative to the extending project, not the base file.
  • noCheck (5.6) — emit without type-checking, the mirror image of noEmit. Useful when a separate step does the checking.
  • isolatedDeclarations (5.5) — require explicit types on exports so .d.ts files can be produced per file, in parallel, without the checker.
  • rewriteRelativeImportExtensions (5.7) — let you write import "./util.ts" and have tsc emit ./util.js.
  • libReplacement (5.8) — opt in to swapping lib.dom for an npm package such as @typescript/lib-dom; off by default since 6.0.
  • skipLibCheck — skip checking .d.ts files in node_modules. Still the single cheapest speed-up, even on the native compiler.

tsconfig.json answers two questions that are easy to conflate but completely independent: which files are in the program (include/exclude/files/extends) and how strictly are they checked (strict and its family). --showConfig exists because both questions get harder to answer by eye once extends is involved — it’s the compiler telling you the truth about its own merged state instead of you tracing an inheritance chain by hand. And paths belongs to the first question’s cousin, module resolution, which is a checker-only concern: it changes what tsc believes a specifier points to, never what a runtime loader does with that same specifier — proving that gap (a real Cannot find module after a clean compile) is the fastest way to internalize that a tsconfig is instructions to the checker, not to the JavaScript that eventually runs.

ChoiceBenefitCost
include/exclude globsScales automatically as files are addedEasy to include something you didn’t mean to (a .spec.ts without an exclude entry)
files (explicit list)Exact, unambiguous — no surprise inclusionsDoesn’t scale; every new file needs a manual edit
extends a shared base configOne strictness/target policy for a whole monorepoA flag you don’t expect can be inherited silently — always sanity-check with --showConfig
paths for internal aliasesCleaner imports (@utils/x instead of ../../../utils/x)Type-only — a tsc-only build silently ships a broken import unless something else (a bundler, Node’s own imports field) also rewrites it
--extendedDiagnostics / --generateTraceReal numbers instead of a vague “the build feels slow”--generateTrace’s output is large and needs a trace viewer to read; --extendedDiagnostics alone won’t tell you which file or type is expensive
  • paths is a real module alias, like a bundler’s. It only tells tsc’s checker where to resolve a specifier for type-checking — proven above: a clean tsc build with paths still emits the unresolved specifier verbatim, and Node throws Cannot find module on it at runtime unless a bundler or a real runtime alias mechanism also understands it.
  • extends only inherits compilerOptions. Since 3.9 it also inherits include/exclude/files from the base config unless the extending file sets its own — a config that looks like it only sets a few compiler flags can silently bring along a base’s file list too.
  • A file listed in both files and matched by include is an error, or gets deduplicated. Neither — tsc --showConfig’s real output above lists it twice, harmlessly; it’s just a sign the files entry was redundant.
  • --extendedDiagnostics’s Types count is the number to watch for a slow build. Instantiations is usually the more actionable number — it’s what explodes when a generic or a recursive conditional type re-derives the same computation repeatedly, often while Types still looks unremarkable.
  • Does a generated tsconfig.json set paths and assume that’s enough for the import to work at runtime with a plain tsc build? Verify it the way this lesson did: compile for real, run the output directly, and see whether Cannot find module appears.
  • Does a config extends a shared base without checking what that base actually sets? Run tsc --showConfig against the merged result before trusting a flag is (or isn’t) on — the answer is one command away, not a guess from reading two files by eye.
  • Is exclude used where files/include scoping would be exact — for example, excluding **/*.spec.ts instead of never including test files in the main config in the first place? Both can work, but a broad exclude is easy to accidentally widen later by adding a file that matches the excluded pattern by name alone.
  • A generated tsconfig copied from a tutorial that still sets "moduleResolution": "node" or "baseUrl" — both are removed in TypeScript 7 (a hard error, not a silent no-op) — is a sign the generation wasn’t checked against this project’s real TypeScript version.

🐛 Spot the bug

{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@shared/*": ["../shared/src/*"]
}
},
"include": ["src/**/*"]
}
Show the answer

Two problems, both invisible just from reading this file in isolation. First, there’s no "rootDir" — with outDir set and rootDir left at its default, this is exactly the gotcha from earlier in this lesson: tsc stops with a hard TS5011 error and still writes files to a nested path, not a silent success. Second, paths maps @shared/* into a sibling package’s src folder — that compiles fine (the checker can see the files), but nothing rewrites @shared/* at emit time, so unless a bundler is also in this build (and reads the same paths entry), the compiled output will throw Cannot find module '@shared/utils' the moment it runs, exactly as this lesson proved with a real node dist/index.js run. Reviewing a generated tsconfig means checking both: does rootDir need to be explicit given this outDir, and does anything other than tsc itself actually resolve every paths entry at runtime?

Since TypeScript 6.0, what is the default value of strict?
What does strictNullChecks change?
With noUncheckedIndexedAccess, what is the type of arr[i] for const arr: number[]?
After upgrading to TypeScript 6/7, @types/node is installed but Node globals are not found. Why?
Your tsconfig has "baseUrl": "./src". What is the TypeScript 7 replacement?
After a clean tsc build of this project, running node dist/index.js (which imports from @utils/math) throws Cannot find module '@utils/math'. Why, if tsc compiled it with zero errors?
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "paths": { "@utils/*": ["./src/utils/*"] }
  },
  "include": ["src/**/*"],
  "files": ["src/extra-entry.ts"]
}
Which --extendedDiagnostics number is most useful for spotting a runaway generic or recursive conditional type specifically?
Files:              83
Instantiations:  34403
Check time:      0.107s