tsconfig in Depth: Core Compiler Options (Part 1)
Understand the tsconfig.json compiler options that control what JavaScript TypeScript emits, which type-checking strictness rules are enforced, and how modules interoperate — covering target, module, moduleResolution, lib, the full strict family, and the interop/module-syntax options.
tsconfig.json is where a TypeScript project's actual behavior is decided — the same source code can compile to different JavaScript, accept or reject different patterns, and interoperate differently with other module systems purely based on these settings. This lesson (part 1 of 2) covers the core compiler options every project should understand deliberately rather than copy-pasting from a template.
Target, module, and lib: what code you emit and what APIs you can use
These four options together decide what JavaScript syntax your compiled output uses, what module system it's wrapped in, how TypeScript resolves import specifiers to files, and what global APIs you're allowed to reference in your source — get any of them mismatched with your actual runtime and you get either broken output or false type errors.
target controls how new the emitted JavaScript syntax is allowed to be — "ES2015" downlevels class fields and arrow functions to older equivalents, "ESNext" emits whatever the latest syntax TypeScript understands with no downleveling at all. Set it to match the oldest runtime your code actually needs to support; setting it newer than your runtime supports ships syntax the runtime can't parse.
module controls the emitted module format — "CommonJS" for require/module.exports-based Node, "ESNext" or "ES2022" for native ES modules, "NodeNext" to let file extensions and package.json's "type" field decide per-file. moduleResolution controls the algorithm used to turn an import specifier string into an actual file path — "node"/"node10" mimics older Node resolution, "bundler" mimics how modern bundlers (Vite, esbuild) resolve imports, and "node16"/"nodenext" mimic Node's actual ESM-aware resolution including package.json exports maps.
lib is a list of ambient type declaration files to include — "DOM" gives you document, window, and every browser API type; "ES2022" gives you the types for that ECMAScript version's built-ins (Array.prototype.at, top-level Promise.allSettled, etc). Omitting "DOM" in a Node-only project is common and correct — it prevents accidentally referencing window in server code that will never run in a browser.
The strict family: catching bugs at compile time
strict is a single umbrella flag that turns on a whole family of stricter checks at once — it's the option the TypeScript team recommends every project enable, since it front-loads a large class of real bugs into compile errors instead of runtime surprises. The individual flags it bundles can also be toggled independently, which matters when migrating an older codebase incrementally.
strictNullChecks is the single most impactful flag in the family: without it, null and undefined are silently assignable to every type, meaning a string parameter can secretly be null and TypeScript won't warn you; with it on, null/undefined must be explicitly included in a type's union (string | null) to be allowed, which is what makes optional chaining and non-null assertions meaningful tools rather than decoration.
noImplicitAny requires every value that would otherwise be inferred as the escape-hatch any type to have an explicit annotation instead — this is usually the very first strict flag a project should turn on, since implicit any silently disables type checking wherever it occurs. strictFunctionTypes makes function parameter types checked contravariantly (correctly, per type theory) rather than the looser bivariant checking TypeScript used historically, catching some real unsoundness in callback assignments.
strictPropertyInitialization requires every class property to be assigned either at declaration or in the constructor, catching a property that's typed as non-optional but never actually gets set. noImplicitThis flags a this used inside a function whose type can't be inferred, usually revealing a genuinely misused callback. useUnknownInCatchVariables (covered in the Async TypeScript lesson) types catch-block variables as unknown rather than any, forcing narrowing before use.
Narrower safety nets: indexed access, optional properties, overrides
noUncheckedIndexedAccess makes indexed access on an object or array type (obj[key], arr[i]) include undefined in its result type, reflecting the real possibility that the key or index doesn't exist — without it, TypeScript optimistically assumes arr[i] is always the array's element type even at an out-of-bounds index.
exactOptionalPropertyTypes tightens what an optional property (prop?: string) actually means: without it, prop?: string silently also allows explicitly assigning undefined to prop; with it, prop?: string means 'may be omitted entirely' but explicitly setting it to undefined is a distinct, disallowed case unless the type says prop?: string | undefined. This distinction matters for APIs where 'omitted' and 'explicitly undefined' have different runtime meaning (e.g. a PATCH request body).
noImplicitOverride requires any class method that overrides a base class method to be marked with the override keyword explicitly — this catches the bug where a base class method is renamed or removed and a subclass's method that was meant to override it silently becomes an unrelated, dead method instead.
• noUncheckedIndexedAccess — arr[i] and obj[key] include undefined in their result type • exactOptionalPropertyTypes — distinguishes 'property omitted' from 'property explicitly set to undefined' • noImplicitOverride — requires the override keyword on methods that override a base class method • allowUnusedLocals — when false (the strict-friendly setting), flags declared-but-never-used local variables • allowUnusedParameters — when false, flags declared-but-never-used function parameters (prefix with _ to opt out per-parameter)
Interop and module syntax options
esModuleInterop fixes a real mismatch between CommonJS and ES modules: CommonJS has no native 'default export' concept, so without this flag, import React from "react" against a CommonJS-built package can behave incorrectly or require an awkward import * as React from "react" workaround instead. Enabling it (the default in modern project templates) makes default imports work intuitively against CommonJS packages.
allowSyntheticDefaultImports is the type-checking-only counterpart to esModuleInterop's actual code transform — it silences the type error for a default import against a module with no real default export, without changing what code gets emitted; it's typically implied by esModuleInterop and mainly relevant when a separate tool (not tsc) does the actual transpiling.
isolatedModules requires every file to be independently transpilable without full-program type information — this matters because tools like Babel, esbuild, and SWC process one file at a time and don't type-check, so they need syntax alone to tell type-only exports from value exports (hence the import type requirement from the Modules lesson), and to reject constructs like const enum that need cross-file information to compile correctly.
verbatimModuleSyntax is a newer, stricter successor to isolatedModules-era rules: it requires every import/export to be written exactly as it should be emitted — a type-only import must use import type, and any import that's used only for its type is *not* silently elided unless marked that way, removing subtle ambiguity about what a given import line does at runtime. resolveJsonModule allows import data from "./config.json" to work directly, with TypeScript inferring a type from the JSON file's actual literal shape.
A realistic strict tsconfig.json excerpt
"strict": true already implies strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, noImplicitThis, and useUnknownInCatchVariables — they're listed individually in the lesson text for clarity, not because they need repeating here. verbatimModuleSyntax subsumes most of what isolatedModules used to check; recent TypeScript versions recommend enabling verbatimModuleSyntax directly on new projects rather than isolatedModules alone.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"allowUnusedLocals": true,
"allowUnusedParameters": false,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true
}
}Code Examples
A Node backend running on CommonJS-based packages sets module: "CommonJS" to match its runtime, deliberately omits "DOM" from lib since there's no browser environment, and turns on esModuleInterop because most published npm packages still ship CommonJS-shaped default exports.
Common mistake: Including "DOM" in lib for a pure backend project would let server code accidentally reference window or document and only fail at runtime, since the type checker would incorrectly treat those globals as available.
noUncheckedIndexedAccess forces a check before using scores["bob"], correctly reflecting that the key might not exist. exactOptionalPropertyTypes distinguishes 'no name provided' (omit the key) from 'name explicitly set to undefined,' which matters for APIs like PATCH requests where the two have different meanings.
const bobScore = scores["bob"]; // number | undefined
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}// With noUncheckedIndexedAccess: true
const scores: Record<string, number> = { alice: 90 };
const bobScore = scores["bob"]; // typed as number | undefined, not number
console.log(bobScore.toFixed(1)); // compile error: bobScore might be undefined
// With exactOptionalPropertyTypes: true
type Patch = { name?: string };
const p: Patch = { name: undefined }; // compile error — omit the key instead
const p2: Patch = {}; // OK — property genuinely omittedImportant Rules
Turn on strict: true on every new project from day one — retrofitting it onto a large untyped codebase later is far more painful than starting with it.
Match target to the oldest runtime you actually support, and lib to the environment you actually run in (include "DOM" only for browser-facing code).
Prefer verbatimModuleSyntax (or isolatedModules on older TypeScript versions) whenever your build pipeline uses a non-typechecking transpiler like esbuild, SWC, or Babel.
Enable noUncheckedIndexedAccess for any project that does meaningful work with dynamic object/array indexing — it catches a very common class of 'assumed it exists' bugs.
Set esModuleInterop: true in virtually every project consuming npm packages — the default-import ergonomics it provides are worth it almost universally.
Where this shows up
Every TypeScript project starts with a tsconfig.json, and the choices made here — strict or not, which module format, which lib — ripple through every file in the project for its entire lifetime. Framework CLIs (Vite, Next.js, NestJS) generate a sensible starting tsconfig automatically, but understanding what each option actually does is what lets a team confidently adjust it as their project's runtime targets, build tooling, or strictness needs change.
{ "compilerOptions": { "target": "ES2022" } } // no strict, no noImplicitAny
{ "compilerOptions": { "target": "ES2022", "strict": true } }
Without strict mode, TypeScript silently allows implicit any types and null/undefined assignability almost everywhere, which means large classes of real bugs simply aren't checked — most of TypeScript's actual safety value comes from the strict family being on.
import React from "react"; // with esModuleInterop: false and a CommonJS React build import * as React from "react"; // without esModuleInterop // or, preferably: // "esModuleInterop": true in tsconfig, then: import React from "react";
Without esModuleInterop, a default import against a CommonJS module that has no real default export either fails to compile or produces incorrect runtime behavior; the fix is either the namespace-import workaround or, better, enabling esModuleInterop project-wide.
[multiple-choice] What does enabling strict: true in tsconfig.json actually do? A. It enables a single specific rule about semicolons B. It bundles on a family of stricter checks at once, including strictNullChecks, noImplicitAny, and several related flags C. It only affects the emitted JavaScript's syntax level, not type checking D. It disables all type checking to make the compiler run faster
B. It bundles on a family of stricter checks at once, including strictNullChecks, noImplicitAny, and several related flags — strict is an umbrella flag that turns on a whole group of individually-toggleable stricter checks together — strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, noImplicitThis, and useUnknownInCatchVariables among them — which is why the TypeScript team recommends it as the default for new projects.
[true-false] With noUncheckedIndexedAccess: true, accessing an array by index (arr[i]) returns a type that includes undefined, even if the array's element type doesn't normally include it. A. True B. False
A. True — noUncheckedIndexedAccess reflects the real possibility that an index is out of bounds by adding undefined to the result type of any indexed access, forcing a check before using the value — without it, TypeScript optimistically assumes every index is valid.
[multiple-choice] Why does esModuleInterop matter when importing from CommonJS-built npm packages? A. It makes TypeScript compile faster for large projects B. CommonJS has no native default-export concept, so without this flag, default imports against CommonJS packages can fail or behave unexpectedly C. It is required to use any third-party package at all D. It changes the target ECMAScript version of the emitted code
B. CommonJS has no native default-export concept, so without this flag, default imports against CommonJS packages can fail or behave unexpectedly — CommonJS modules use module.exports rather than a native default export mechanism, so esModuleInterop adds the interop shimming needed for import X from "pkg" to work intuitively against such packages, instead of requiring a namespace import workaround.
Write a tsconfig.json compilerOptions block for a browser-only TypeScript library (no Node APIs needed) that: targets ES2020, includes DOM types, enables full strict mode plus noUncheckedIndexedAccess, and sets esModuleInterop and resolveJsonModule to true.
{ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM"], "strict": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "resolveJsonModule": true } } lib should be an array including both an ECMAScript version and "DOM".