Chapter 20 of 24 · TypeScript for Beginners — Complete Free Tutorial
Strict Mode and tsconfig Deep Dive
Every example in the TypeScript for Beginners series has quietly depended on a tsconfig.json you may never have opened. This twentieth chapter of the 24-chapter series reads that file line by line: which options shape your program, which flags actually make up the strict family, how configuration scales to a large repository with project references, and how to migrate an inherited JavaScript config one flag at a time instead of in a single painful day.
Anatomy of tsconfig.json
TypeScript looks for tsconfig.json in the current folder and then in every folder above it, so one file at the repository root configures the whole tree. Six keys cover almost everything you will write:
| Key | Job |
|---|---|
compilerOptions | Every flag discussed below |
include | Globs deciding which files are checked at all |
exclude | Globs removed from the include result |
files | An explicit list of files, used by solution-style configs |
extends | Inherits another config; the closer file wins |
references | Other projects built before this one (project references) |
A typical React application splits base options from app-specific ones:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
Expected output: npx tsc --showConfig prints the merged result, so you never have to guess what inheritance produced:
{
"compilerOptions": {
"target": "es2022",
"module": "es6",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true
},
"include": ["src", "vite.config.ts"]
}
Two inheritance rules catch people out. include, exclude and files are not merged — the inheriting file replaces them wholesale — while relative paths written in the base file keep resolving against that base file, not against the config that extends it.
The options that shape your program
These are the flags you will meet in almost every codebase. Everything else is specialisation:
| Option | Example | What it controls |
|---|---|---|
target | ES2022 | The ECMAScript version of emitted JavaScript, and which newer syntax is available to you |
module | ESNext | The module system of the output: ESNext, NodeNext, CommonJS, Preserve |
moduleResolution | bundler | How import './x' maps to files: bundler (Vite, esbuild), node16/nodenext (Node with "type": "module") |
lib | ["ES2022", "DOM"] | Which ambient declarations exist: language features plus the browser's DOM or Node's APIs |
jsx | react-jsx | How JSX in .tsx files is lowered, and whether you must import React |
noEmit | true | Type-check only and write nothing to disk — correct when a bundler emits the JavaScript |
skipLibCheck | true | Skips checking .d.ts files; the single biggest compile-time speed win and safe in applications |
isolatedModules | true | Requires every file to be transpilable on its own, which esbuild, SWC and Vite demand |
paths | {"@/*": ["src/*"]} | Aliases so imports read @/utils instead of ../../utils |
incremental | true | Stores a .tsbuildinfo file so repeat checks only redo what changed |
One pairing rule matters: moduleResolution: "bundler" only works with module set to preserve or es2015 and later — mix it with commonjs and you get error TS5095: Option 'bundler' can only be used when 'module' is set to 'preserve' or to 'es2015' or later. And isolatedModules has a sharp edge you will meet in real code: a type cannot be re-exported by value, so export { Course } from './models' reports error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'. Bundlers translate one file at a time and cannot know that Course exists only at type level unless you say so.
The strict family
"strict": true is a bundle, not a single behaviour. It enables noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict and useUnknownInCatchVariables — the last two became part of the bundle as TypeScript matured, which is why older articles list a shorter set. Three flags you should often want are not included:
| Flag | In strict? | What it adds, and what it catches |
|---|---|---|
noImplicitAny | Yes | Every un-annotated parameter must be inferrable — error TS7006: Parameter 'role' implicitly has an 'any' type. |
strictNullChecks | Yes | null and undefined are their own types — error TS2322: Type 'string | undefined' is not assignable to type 'string'. |
useUnknownInCatchVariables | Yes | The catch parameter becomes unknown — error TS18046: 'err' is of type 'unknown'. |
noUncheckedIndexedAccess | No | Index reads gain | undefined, so roles[0] must be checked before use |
exactOptionalPropertyTypes | No | { name?: string } rejects an explicit undefined, matching how you read the declaration |
noUnusedLocals / noUnusedParameters | No | error TS6133: 'theme' is declared but its value is never read. — dead code fails the build |
exactOptionalPropertyTypes is the subtlest of the three. Under it, assigning undefined to an optional property is rejected with error TS2375: Type '{ name: undefined; }' is not assignable to type 'User' with 'exactOptionalPropertyTypes: true'; if undefined is a genuine value, widen the declaration to name?: string | undefined and the intent is documented in the type itself.
Reading a real error report
Run npx tsc --noEmit on a project that has everything switched on and the output is one line per problem, in file order:
npx tsc --noEmit
Expected output:
src/profile.ts(6,24): error TS18048: 'user' is possibly 'undefined'.
src/profile.ts(10,17): error TS7006: Parameter 'role' implicitly has an 'any' type.
src/profile.ts(10,23): error TS6133: 'active' is declared but its value is never read.
src/profile.ts(11,9): error TS6133: 'theme' is declared but its value is never read.
src/profile.ts(18,15): error TS18046: 'err' is of type 'unknown'.
src/profile.ts(22,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
Notice the shape: file(line,column), then a stable error code you can search for, then the message. Learning the handful of codes you see daily — TS2322, TS7006, TS18046, TS6133, TS2578 — turns a wall of red into a short checklist.
Project references for large repositories
When one repository holds several packages, checking everything from one config becomes slow and circular imports slip through. Project references split the work: each package is its own project, declares composite: true, and the root becomes a solution file that only lists references.
// root tsconfig.json
{ "files": [], "references": [{ "path": "./packages/core" }, { "path": "./packages/app" }] }
// packages/core/tsconfig.json
{
"compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "outDir": "dist" },
"include": ["src"]
}
Build the graph with the multi-project flag rather than plain tsc:
npx tsc -b
Expected output: silence and exit code 0 the first time; a second run prints nothing at all because packages/*/tsconfig.tsbuildinfo records that nothing changed. Change one file in core and only core and the projects referencing it are re-checked. The declaration maps also let your editor jump into the source behind a dependency instead of a generated .d.ts.
Migrating a configuration incrementally
If you inherit a JavaScript project, do not enable everything at once. Order matters, and each step should leave the build green:
- Start checking without converting. Add
"allowJs": trueand"noEmit": trueso.jsand.tsfiles coexist while the bundler keeps emitting the JavaScript. - Choose files one at a time. Turn on
"checkJs": trueglobally, or add// @ts-checkat the top of a single file — a reviewed file reports errors while an untouched file stays quiet. - Enable
noImplicitAnyfirst. It is the highest-value flag per error, and every fix is an annotation you wanted to write anyway. - Then
strictNullChecks. This produces the largest wave of errors; fix them with narrowing and guards, never with blanket assertions. - Add the optional flags —
noUncheckedIndexedAccess,exactOptionalPropertyTypes,noUnusedLocals,noUnusedParameters— as separate commits so each diff is reviewable. - Flip
"strict": trueonce the individual flags are on, then keepnpx tsc --noEmitgreen in CI from that day forward (chapter 23 sets up the gate).
The destination is worth the climb: strict plus noUncheckedIndexedAccess is the configuration used by every example in the rest of this series.
Common mistakes
- Assuming
strictcovers everything —noUncheckedIndexedAccessandexactOptionalPropertyTypesare opt-in, so array-heavy code still slips through. - Turning on
strictandallowJstogether on day one — you will drown in unrelated errors; migrate flag by flag, in the order above. - Forgetting
noEmitwhile a bundler builds the app —tscthen writes stale JavaScript next to your sources and tools start loading the wrong files. - Leaving
includetoo wide — a config withoutincludealso checksdist, scripts and fixtures, which is how “my build is suddenly slow” happens. - Re-exporting types by value under
isolatedModules— writeexport type, or the transpiler emits an import for something that does not exist at runtime.
Practice checklist
- Run
npx tsc --showConfigin your own project and match five printed options back to the file they came from. - Add
noUncheckedIndexedAccess, read the first five errors, and fix them by narrowing rather than asserting. - Switch on
noUnusedLocalsandnoUnusedParameters, then delete or underscore-prefix everything it flags. - Deliberately break a pairing — set
moduleResolutiontobundlerwithmodule: "commonjs"— and read TS5095 until it makes sense. - Split one repository into two projects with
referencesand runnpx tsc -btwice to see the second run do nothing.
What's next?
Your configuration now enforces the rules on purpose rather than by accident. Next you apply the same rigour to the one part of JavaScript that types cannot see: Error Handling and Result Types.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.