What tsconfig.json Actually Controls
The file has two jobs: deciding which files belong to the project, and setting how they are checked and compiled. File selection lives at the top level in include, exclude and files; everything else lives inside compilerOptions.
There is a rule about exclude that surprises people and is worth learning before it costs you an afternoon. exclude only filters the list produced by include. It does not stop a file being part of the project if something in the project imports it. Excluding a folder and then importing from it means the file is compiled anyway, and the exclusion has silently done nothing.
extends lets one config inherit from another, which is how monorepos share settings and how the community-published @tsconfig/ base configs work — you inherit a known-good target and lib for a particular runtime instead of guessing. You can also have more than one config in a project, commonly a base file plus a tsconfig.build.json that excludes test files; point at a specific one with tsc -p tsconfig.build.json.
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// The exclude trap:
// "exclude": ["src/legacy"]
// but somewhere in src:
// import { helper } from "./legacy/helper.js";
// The file is compiled and checked anyway. exclude only filters
// the initial include glob; imports always pull files in.
// A build-only config that inherits everything else
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
} filesis an explicit list with no globbing. It is useful for a tiny project or a declaration-only package, and unmanageable for anything larger — useinclude.
target, lib, module and moduleResolution
These four decide what JavaScript comes out and what the compiler assumes is available. They are also the options most often copied from a random tutorial and then blamed for mysterious errors, so it is worth knowing what each one means.
target is the JavaScript version to emit. A low target makes the compiler rewrite modern syntax into older equivalents, producing bulkier and slower output; a target higher than your runtime supports produces code that crashes there. For Node, match the Node version you deploy on. For the browser, ES2020 or later is safe for anything current.
lib is the set of built-in type declarations available — which APIs the compiler believes exist. It defaults to something sensible for your target, and you adjust it in two situations: adding "DOM" for browser APIs such as document and fetch, or adding a newer library entry to use a method your runtime has but your target predates. Note that lib only changes what the types say; it does not add the feature to an old runtime.
module and moduleResolution should be set together and must match how your code will actually be loaded. NodeNext for both is right for Node projects; "module": "ESNext" with "moduleResolution": "bundler" is right when a bundler such as Vite handles the loading. Mismatching them is the usual cause of import errors that make no sense.
// A Node service
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
// A browser app built with a bundler
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true
}
}
// Missing "DOM" in lib produces this, and it confuses everyone:
// ERROR: Cannot find name 'document'.
// ERROR: Cannot find name 'localStorage'. libis a promise about the runtime, not a polyfill. AddingES2023tolibmakesArray.prototype.findLasttypecheck; it does not make it exist in a browser that lacks it.
Inside strict
strict: true is a switch that turns on a family of individual checks. You can set them one by one, and knowing what each does is useful even if you never do — it tells you which check produced an error you are staring at.
The one that changes your day-to-day experience most is strictNullChecks. With it off, null and undefined are assignable to every type, and TypeScript will never warn you about the single most common runtime error in JavaScript. Turning it off to make errors go away removes most of the reason to use TypeScript in the first place.
The right time to enable strict is at the start of a project, when there is no code to fix. Enabling it on a large existing codebase produces a wall of errors and usually ends with someone turning it back off. The workable migration is one sub-flag at a time, starting with noImplicitAny, fixing what it reports, committing, and moving on.
noImplicitAny— errors on a parameter or variable that would silently becomeanystrictNullChecks—nullandundefinedstop being assignable to everything; the single most valuable checkstrictFunctionTypes— checks function parameter types more carefully when one function type is assigned to anotherstrictBindCallApply— checks the arguments passed throughbind,callandapplystrictPropertyInitialization— class fields must be definitely assigned (requiresstrictNullChecks)noImplicitThis— errors whenthishas no known typeuseUnknownInCatchVariables— acatchvariable isunknownrather thananyalwaysStrict— parses files in JavaScript strict mode and emits"use strict"
- Check the release notes for your TypeScript version before assuming this list is complete — new checks are occasionally added to the
strictfamily, which is why a compiler upgrade can surface errors in code you did not touch.
Useful Options That strict Does Not Include
Several valuable checks live outside strict, usually because they are stricter than most codebases can adopt at once. On a new project they are worth turning on immediately, while there is nothing to fix.
noUncheckedIndexedAccess is the most valuable of them. It makes every array index and every index-signature lookup return T | undefined, which is the truth, and it catches a genuine class of bug around splitting strings, reading query parameters and looking up dictionary keys. It also adds real work, so decide early rather than late.
exactOptionalPropertyTypes distinguishes a property that is absent from one that is present and set to undefined. That difference matters when you serialise objects or send patch requests, and matters not at all otherwise.
noImplicitOverride makes the override keyword compulsory on methods that override a parent, so renaming the parent method turns silent dead code into an error. noFallthroughCasesInSwitch catches a missing break. noUnusedLocals and noUnusedParameters keep dead code out — though many teams prefer to let ESLint handle those, because an unused variable failing the build during a debugging session is irritating.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true
}
} forceConsistentCasingInFileNamesdeserves a mention of its own. Windows and macOS treat file names case-insensitively while Linux does not, so an import written./Utils.jsfor a file calledutils.tsworks on a laptop and fails on the build server. This option catches it before you push.
Emit Options, and What tsc Does Not Do
Set expectations here, because they are commonly wrong. tsc checks types and translates each file to JavaScript. It does not bundle, it does not minify, it does not tree-shake, and it does not process CSS or images. In a front-end project a bundler does all of that, and tsc's only job is checking — which is why those projects set noEmit: true.
For a Node service or a published library, tsc is the build tool. outDir and rootDir keep the output separate from the source. sourceMap: true makes stack traces and breakpoints point at your .ts lines. declaration: true emits the .d.ts files that let other TypeScript projects see your types, which is essential if you publish a package and pointless if you do not.
noEmitOnError: true is worth adding wherever tsc produces your build. By default the compiler writes output even when it reported errors, on the theory that you may want to run the code anyway. For a deployment build, that default is the wrong one.
// A Node service or library built with tsc
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noEmitOnError": true,
"skipLibCheck": true,
"incremental": true
}
}
// A front-end app: the bundler builds, tsc only checks
{
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true
}
}
// package.json — the commands that matter
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"watch": "tsc --watch"
}
} incremental: truewrites a small cache file so later compilations only re-check what changed. On a large project it turns a slow check into a fast one; add the generated.tsbuildinfofile to.gitignore.
Migrating a JavaScript Project, and Splitting a Large One
You rarely start from nothing. To bring TypeScript into an existing JavaScript codebase, turn on allowJs so .js files can live alongside .ts ones and be imported freely. Then convert file by file, starting with the leaves — the small utilities that nothing depends on — and working towards the entry points.
checkJs goes further and typechecks your JavaScript files using inference and JSDoc comments, without renaming anything. That is a good way to find bugs before committing to a conversion, and if it produces too much noise you can enable it one file at a time with a // @ts-check comment at the top.
At the other end of the scale, a large repository can be split with project references. Each package gets its own tsconfig.json with composite: true, and a root config lists them under references. Building with tsc --build then compiles them in dependency order and skips any package whose inputs have not changed, which keeps build times reasonable as the repository grows.
Whatever the setup, one habit matters more than any option: run tsc --noEmit in continuous integration. Editors and bundlers do not enforce types, so without that step a project accumulates errors nobody sees.
// Migrating: JavaScript and TypeScript side by side
{
"compilerOptions": {
"allowJs": true,
"checkJs": false, // turn on when you are ready for the noise
"strict": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
// Opt one JavaScript file in without changing the config
// @ts-check
// Splitting a monorepo with project references
// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/server" },
{ "path": "./packages/web" }
]
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true, // required for a referenced project
"declaration": true,
"outDir": "./dist"
}
}
// Build everything in dependency order, skipping unchanged packages:
// tsc --build - Two comment directives are worth knowing during a migration.
// @ts-expect-errorsuppresses the error on the next line and reports an error if that line turns out to be fine — so the suppression cannot outlive the problem.// @ts-ignoresuppresses silently and stays forever. Prefer the first.
