tsconfig, Demystified
Intermediate · 20 min read · ▶ live playground · ✦ checkpoint
tsconfig.json is the project contract for TypeScript: which files are in the program, how strict the checker should be, and what kind of JavaScript the compiler would emit. Once a project has this file, npx tsc stops being "check this one file" and becomes "check this whole project by these rules."
This is a tool-shaped lesson, so the examples are config and terminal fences. No playground here: the playground is intentionally single-file, and tsconfig.json only makes sense at project scale.
The shape of tsconfig.json
A small config has three top-level ideas:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}compilerOptions are the rules for checking and emitting. This is where almost every flag you argue about lives: strictness, JavaScript output level, module shape, import resolution, whether files are written to disk.
include chooses the starting set of files. In this example, TypeScript starts from .ts files under src.
exclude removes paths from that starting search. It is mostly for build output, generated folders you do not want scanned directly, and the usual node_modules noise. It is not a security boundary, and it is not a promise that TypeScript will never read those files.
Here is the concrete shape of that trap:
Tool transcript: npx tsc --noEmit in a throwaway project
tsconfig.json
include: ["src/**/*.ts"]
exclude: ["src/generated/**"]
src/app.ts imports "./generated/client"
src/generated/client.ts(1,14): error TS2322: Type 'number' is not assignable to type 'string'.That diagnostic comes from the excluded folder because src/app.ts imported it. exclude controlled discovery; the import controlled reachability.
The flags that matter first
Four flags explain most real TypeScript project behavior:
strict turns on the family of checks this course assumes. Keep it on. Code that only works in loose mode is training you to distrust the checker.
target controls the JavaScript language level TypeScript emits toward. If you set "target": "ES2022", the compiler can leave modern syntax alone instead of rewriting it for old runtimes. target is not your package format, and it is not the same thing as browser support policy.
module controls the module syntax in emitted JavaScript: preserve ESM, emit CommonJS, or use a Node-specific mode. This is the flag connected to 9.1's import and export story.
moduleResolution controls how TypeScript turns an import specifier into a file or package. Bundler-built apps usually want "Bundler" because the bundler owns final loading. Node libraries and Node-run apps often want "NodeNext" paired with Node-style module output. The important habit is pairing module and moduleResolution deliberately instead of copying one line from one project and the other line from another.
The common confusion: target does not choose ESM versus CommonJS. module does. A project can target modern JavaScript and still emit CommonJS, or target older JavaScript and still preserve ESM. They answer different questions.
A modern 2026 baseline
For a bundler-built app where Vite, Next.js, esbuild, Bun, or another build tool writes the final JavaScript, this is a strong starting point:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}Line by line:
strict: the course's baseline. It keeps the checker useful instead of decorative.target: "ES2022": modern enough for current tooling without asking TypeScript to rewrite ordinary syntax aggressively.module: "ESNext": preserve ESM so the bundler can do tree-shaking and final packaging.moduleResolution: "Bundler": resolve imports the way bundler-based apps usually expect.verbatimModuleSyntax: keep 9.1 honest; value imports stay,import typeerases.noUncheckedIndexedAccess: indexed lookups include the possibility of missing values, matching the dictionary lesson from Section 5.exactOptionalPropertyTypes: optional properties mean "may be absent" more literally, which makes object contracts sharper.noEmit:tscchecks only; the app's build tool emits.skipLibCheck: skip deep checking of installed declaration files so your project check focuses on your code.include/exclude: make the project file set explicit.
There are legitimate variants. A published Node library may use "module": "NodeNext" and "moduleResolution": "NodeNext". A legacy app may target older JavaScript. A generated client may need a looser folder-specific config. But start from a coherent baseline, then change one flag because your runtime demands it.
noEmit still type-checks
noEmit is often misread as "TypeScript does nothing." It means "do not write JavaScript files." The checker still runs, diagnostics still fail the command, and CI still gets a pass/fail signal.
npx tsc --noEmit
# type-check the project; write no .js filesThat is the same loop from 1.4 and 1.5, now made project-wide. In a modern app you usually have two separate commands:
npx tsc --noEmit
npm run buildThe first command asks "are the types coherent?" The second asks the framework or bundler to produce runnable JavaScript, CSS, assets, and server output. Keeping those jobs separate makes failures easier to read.
Extending shared configs
You do not have to invent every baseline yourself. The @tsconfig/* packages publish shared configs you can extend:
npm install -D @tsconfig/strictest{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}extends loads the base first, then your local config overrides it. compilerOptions merge with local values winning. Your project should still own include and exclude, because the base package cannot know your folder layout.
Use shared configs to reduce ceremony, not to stop thinking. A base can say "strict by default"; your project still decides whether it is a bundler app, a Node library, a test package, or a generated-code boundary.
Next lesson keeps following the compiler's line of sight: when TypeScript resolves an import to a package, where do the types come from? That is the job of declaration files and DefinitelyTyped.
Checkpoint
Answer all three to mark this lesson complete