tsconfig, Deep
The defaults changed in TypeScript 6
Section titled “The defaults changed in TypeScript 6”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:
| Option | Before 6.0 | 6.0 / 7.0 default |
|---|---|---|
strict | false | true |
module | commonjs | esnext |
target | es2020-ish | es2025 — floats to the current year’s ECMAScript |
types | every @types/* in node_modules | [] — nothing unless you list it |
rootDir | inferred from your sources | . — the directory of tsconfig.json |
noUncheckedSideEffectImports | false | true |
libReplacement | true | false |
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 mustbe explicitly set to this or another path to adjust your output's file layout.What tsc --init writes today
Section titled “What tsc --init writes today”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 undersrc.exclude— glob patterns removed from whatincludematched (it does not add files back thatincludenever found).node_modules,.git, and theoutDirare excluded by default even with noexcludekey.files— an explicit list of individual files, always included regardless ofinclude/exclude— the one way to force a specific file into the program.extends— inherits another config’scompilerOptions(and, since 3.9, itsinclude/exclude/filestoo, 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):
{ "compilerOptions": { "target": "es2025", "module": "nodenext", "strict": true }}{ "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.jsnode: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: 83Lines: 58450Identifiers: 49750Symbols: 59787Types: 35364Instantiations: 34403Memory used: 64885KParse time: 0.013sBind time: 0.005sCheck time: 0.107sEmit time: 0.000sTotal time: 0.125sInstantiations 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 TS18046function parseNumber(input: string): number { try { return JSON.parse(input); } catch (e) { console.log(e.message); return 0; }} 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.
What strict actually turns on
Section titled “What strict actually turns on”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 |
|---|---|
strictNullChecks | null and undefined are no longer silently in every type — the biggest win |
noImplicitAny | A value that would fall back to any becomes an error you must resolve |
strictFunctionTypes | Function parameters are checked contravariantly — unsafe callbacks are caught |
strictBindCallApply | bind, call, and apply are type-checked against the function signature |
strictPropertyInitialization | Class fields must be assigned in the constructor (or marked optional) |
useUnknownInCatchVariables | catch (e) gives you unknown, not any, forcing you to narrow |
strictBuiltinIteratorReturn | Built-in iterators return undefined when done, not any (5.6) |
noImplicitThis | A this that would fall back to any (e.g. in a plain function used as a method) becomes an error |
alwaysStrict | Parses 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.
target, module, and lib
Section titled “target, module, and lib”Three options describe the world your code runs in:
target— which JavaScript versiontscemits. The defaultes2025(andesnext) means no down-leveling: modern syntax is passed through, which is what every current runtime wants. Lower targets still down-level newer syntax — butes5is gone (a hard error in 7.0; ES2015 is the floor), and with itdownlevelIteration.module— the module format of the output.nodenextwhen Node runs the output directly,esnextorpreservewhen a bundler does, and the stablenode18/node20when 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.domnow includes what used to live indom.iterableanddom.asynciterable;es2025addsRegExp.escape, theIteratorhelper methods, theSetalgebra methods and theMapupsert methods (getOrInsert,getOrInsertComputed), and theTemporaltypes ship with 6.0. If you use an API and get “does not exist”, yourlibis too old.
Strict-adjacent flags worth adding
Section titled “Strict-adjacent flags worth adding”strict is the floor, not the ceiling. Two more flags catch bugs that strict alone misses:
// noUncheckedIndexedAccess: trueconst 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 caughtnoUncheckedIndexedAccess— indexing an array or record can returnundefined(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 toundefinedfrom a property that is absent, so an optionalname?: stringno longer silently acceptsname: 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. Declaredeclare 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,namespacewith runtime code, class parameter properties,import x = require(). Turn it on if you run.tsfiles 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:
| Removed | Replace with |
|---|---|
baseUrl | fold the prefix into paths: "@app/*": ["./src/app/*"] |
moduleResolution: node / node10 / classic | nodenext (Node runs it) or bundler (a bundler does) |
module: amd / umd / system / none | esnext or preserve, and let a bundler produce other formats |
outFile | a bundler |
target: es5, downlevelIteration | es2015 or newer |
esModuleInterop: false, allowSyntheticDefaultImports: false, alwaysStrict: false | nothing — 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.
Smaller options you will actually use
Section titled “Smaller options you will actually use”${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 ofnoEmit. Useful when a separate step does the checking.isolatedDeclarations(5.5) — require explicit types on exports so.d.tsfiles can be produced per file, in parallel, without the checker.rewriteRelativeImportExtensions(5.7) — let you writeimport "./util.ts"and havetscemit./util.js.libReplacement(5.8) — opt in to swappinglib.domfor an npm package such as@typescript/lib-dom; off by default since 6.0.skipLibCheck— skip checking.d.tsfiles innode_modules. Still the single cheapest speed-up, even on the native compiler.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
include/exclude globs | Scales automatically as files are added | Easy to include something you didn’t mean to (a .spec.ts without an exclude entry) |
files (explicit list) | Exact, unambiguous — no surprise inclusions | Doesn’t scale; every new file needs a manual edit |
extends a shared base config | One strictness/target policy for a whole monorepo | A flag you don’t expect can be inherited silently — always sanity-check with --showConfig |
paths for internal aliases | Cleaner 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 / --generateTrace | Real 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 |
Common Misconceptions
Section titled “Common Misconceptions”pathsis a real module alias, like a bundler’s. It only tellstsc’s checker where to resolve a specifier for type-checking — proven above: a cleantscbuild withpathsstill emits the unresolved specifier verbatim, and Node throwsCannot find moduleon it at runtime unless a bundler or a real runtime alias mechanism also understands it.extendsonly inheritscompilerOptions. Since 3.9 it also inheritsinclude/exclude/filesfrom 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
filesand matched byincludeis an error, or gets deduplicated. Neither —tsc --showConfig’s real output above lists it twice, harmlessly; it’s just a sign thefilesentry was redundant. --extendedDiagnostics’sTypescount is the number to watch for a slow build.Instantiationsis usually the more actionable number — it’s what explodes when a generic or a recursive conditional type re-derives the same computation repeatedly, often whileTypesstill looks unremarkable.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated
tsconfig.jsonsetpathsand assume that’s enough for the import to work at runtime with a plaintscbuild? Verify it the way this lesson did: compile for real, run the output directly, and see whetherCannot find moduleappears. - Does a config
extendsa shared base without checking what that base actually sets? Runtsc --showConfigagainst 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
excludeused wherefiles/includescoping would be exact — for example, excluding**/*.spec.tsinstead of never including test files in the main config in the first place? Both can work, but a broadexcludeis 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?