TypeScript Compiler Options Reference

The TypeScript compiler, tsc, is controlled by dozens of configuration flags that decide how strict type checking should be, what JavaScript syntax gets emitted, and how modules are resolved. These flags usually live in a tsconfig.json file. Getting them right is the difference between TypeScript catching real bugs before they ship and TypeScript either fighting you constantly or quietly letting bugs slip through. This lesson is a practical, in-depth reference to the options you will actually use.

Overview: how the compiler uses configuration

When you run tsc with no arguments, it searches the current directory and each parent directory for a tsconfig.json file. Once found, that directory becomes the project root. You can also point directly at a config with tsc -p path/to/tsconfig.json, or skip config entirely and pass flags straight on the command line (useful for one-off checks, but not for real projects).

A tsconfig.json has two main parts: compilerOptions, which holds the actual flags, and file-selection fields (include, exclude, files) that decide which source files belong to the project. A config can also extend a base config, which is how monorepos and shared style guides distribute a common set of options; options in the extending file override the ones it inherits.

Compiler options fall into a few natural categories, and it helps to think in these buckets rather than memorizing an alphabetical list:

  • Type Checkingstrict and its sub-flags (strictNullChecks, noImplicitAny, strictPropertyInitialization, and others), plus opt-in extras like noUnusedLocals and noImplicitReturns.
  • Modulesmodule, moduleResolution, baseUrl, paths, resolveJsonModule.
  • Emittarget, outDir, rootDir, declaration, sourceMap, noEmit.
  • JavaScript SupportallowJs, checkJs.
  • Interop & CompletenessesModuleInterop, isolatedModules, skipLibCheck.

Two options deserve special attention because beginners often conflate them: target and lib. target controls which ECMAScript version the emitted JavaScript is downleveled to (for example, arrow functions get rewritten to regular functions when targeting very old runtimes) and it also picks a default set of global type declarations. lib lets you decouple that second part — you might target an older runtime for output syntax while still telling the type checker “assume ES2020 globals like Promise.allSettled exist” because you know your runtime or polyfills provide them.

Crucially, none of these options exist once your code runs. Every one of them is a compile-time instruction. The compiler reads the config, builds a full program (your entry files plus every file they import, transitively), applies the requested checks and emit rules, and then produces plain JavaScript with all type annotations erased.

Syntax

A minimal, realistic tsconfig.json looks like this (shown as plain text — this is JSON configuration, not TypeScript source):

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

Reference table of the options you will meet most often:

Category Option Effect
Type Checking strict Turns on the full recommended set of strict checks in one flag.
Type Checking noImplicitAny Errors when a type can’t be inferred and would silently become any.
Type Checking strictNullChecks null and undefined are only assignable where a type explicitly allows them.
Type Checking strictPropertyInitialization Class fields must be assigned in the constructor or explicitly typed to allow undefined.
Type Checking noUnusedLocals Errors on variables that are declared but never read.
Modules module The module format of the emitted JS (CommonJS, ESNext, etc).
Modules moduleResolution The algorithm used to locate imported files/packages.
Emit target ECMAScript version for emitted syntax and default globals.
Emit declaration Also emit .d.ts type declaration files.
Interop esModuleInterop Allows default-style imports of CommonJS modules.
Completeness skipLibCheck Skips type checking of .d.ts files, speeding up builds.

Examples

Example 1: strictPropertyInitialization and strictNullChecks together

class UserProfile {
  name: string;
  email: string | null;

  constructor(name: string, email: string | null = null) {
    this.name = name;
    this.email = email;
  }

  getContact(): string {
    if (this.email === null) {
      return `${this.name} (no email on file)`;
    }
    return `${this.name} <${this.email}>`;
  }
}

const user1 = new UserProfile("Ada Lovelace");
console.log(user1.getContact());

const user2 = new UserProfile("Grace Hopper", "grace@navy.mil");
console.log(user2.getContact());

Output:

Ada Lovelace (no email on file)
Grace Hopper 

With strict enabled, every class field must either be assigned in the constructor or have a type that admits undefined — that’s strictPropertyInitialization at work. And because email is typed string | null, getContact must narrow it with an explicit check before accessing .length-style members — that’s strictNullChecks. Without strict, both of these mistakes would compile silently and only surface as runtime errors.

Example 2: noImplicitAny forces you to state your intent

function calculateDiscount(price: number, percentage: number): number {
  return price - price * (percentage / 100);
}

function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`;
}

const original = 149.99;
const discounted = calculateDiscount(original, 20);
console.log(`Original: ${formatCurrency(original)}`);
console.log(`Discounted: ${formatCurrency(discounted)}`);

Output:

Original: $149.99
Discounted: $119.99

Every parameter here has an explicit type. Under noImplicitAny (part of strict), if you left off : number on price or percentage, the compiler would refuse to guess and would report an error instead of silently treating the parameter as any. This is one of the highest-value checks TypeScript offers, because an untyped parameter is a hole the type checker can’t see through anywhere it’s used.

Example 3: an unused local and modern syntax support

interface Config {
  retries?: number;
  timeoutMs?: number;
}

function loadConfig(overrides: Config): Required {
  const debugFlag = false;
  const retries = overrides.retries ?? 3;
  const timeoutMs = overrides.timeoutMs ?? 5000;
  return { retries, timeoutMs };
}

const config = loadConfig({ timeoutMs: 8000 });
console.log(`retries=${config.retries} timeoutMs=${config.timeoutMs}`);

Output:

retries=3 timeoutMs=8000

Notice debugFlag is declared but never read. Plain strict mode does not flag this — noUnusedLocals is a separate, opt-in option that would report it if enabled. This example also relies on the nullish coalescing operator ??, native syntax as of ES2020; with an older target the compiler would downlevel it into equivalent helper logic rather than reject it, since ?? has been supported since TypeScript 3.7 regardless of target.

Under the hood: what tsc actually does

When you invoke tsc, it works through a predictable pipeline:

  • 1. Locate configuration. It finds the nearest tsconfig.json (or uses the one passed via -p) and resolves any extends chain, merging options with the most specific file winning.
  • 2. Build the file list. It expands include/exclude/files into an initial set of root files.
  • 3. Build the program. Starting from the root files, it follows every import/require/triple-slash reference to pull in dependencies, building a complete dependency graph called the “program.”
  • 4. Load the standard library. Based on target and lib, it loads the matching lib.d.ts declaration files so globals like Array, Promise, or fetch have known types.
  • 5. Bind and check. The binder resolves every identifier to its declaration, then the checker walks the program applying every enabled type-checking flag, producing diagnostics for anything that violates them.
  • 6. Emit (unless noEmit). If there are no fatal errors (or if noEmitOnError is off, even if there are), the compiler strips all type annotations, downlevels syntax to match target, and writes out plain .js files — plus .d.ts files if declaration is set.

The key thing to internalize: step 6 erases every type. The compiled JavaScript that actually runs in Node or a browser has no notion of string | null, no interfaces, no generics — those only ever existed to make step 5 possible. This is why a console.log of a typed value at runtime prints a plain JS value with no trace of its TypeScript type.

Common Mistakes

Mistake 1: leaving out explicit types and expecting the compiler to infer everything

function add(a, b) {
  return a + b;
}
console.log(add(2, 3));

Under strict (specifically noImplicitAny), tsc reports: TS7006: Parameter 'a' implicitly has an 'any' type. (and the same for b). TypeScript can only infer a parameter’s type from a default value or from context it can see — a bare parameter with no annotation and no inferable context is an error under strict, not a silent any.

function add(a: number, b: number): number {
  return a + b;
}
console.log(add(2, 3));

Output: 5

Mistake 2: assuming a nullable type is safe to use directly

function getLength(value: string | null) {
  return value.length;
}

This reports TS2531: Object is possibly 'null'. A common misconception is that disabling one strict sub-flag (say, turning off noImplicitAny in a legacy file) also relaxes null checking — it doesn’t. Each sub-flag under strict is independent, and strictNullChecks will still stop you from dereferencing a possibly-null value until you narrow it.

function getLength(value: string | null): number {
  if (value === null) {
    return 0;
  }
  return value.length;
}

console.log(getLength("hello"));
console.log(getLength(null));

Output:

5
0

Best Practices

  • Start every new project with "strict": true rather than turning on sub-flags piecemeal — it’s far easier to keep strictness than to retrofit it onto a large codebase later.
  • Commit tsconfig.json to version control; compiler options are part of your project’s contract, not a personal preference.
  • Enable skipLibCheck in most projects — it skips type-checking third-party .d.ts files, which speeds up builds and avoids errors you can’t fix anyway.
  • Set target to match what your actual runtime supports (check your minimum supported Node version or browser matrix) rather than defaulting to the oldest possible value.
  • In monorepos, put shared options in a base config and use extends from each package’s tsconfig.json instead of duplicating flags.
  • Prefer isolatedModules: true if your build pipeline transpiles files one at a time (Babel, esbuild, SWC) — it forces you to avoid TypeScript features that require whole-program knowledge to compile.
  • Don’t disable a strict flag project-wide to silence one file’s errors — fix the file, or use a scoped // @ts-expect-error comment with a reason instead.
  • Use incremental: true (and composite for multi-project builds) on large codebases to speed up repeated compiles via build info caching.

Practice Exercises

  • Create a fresh tsconfig.json with "strict": true and write a function with an untyped parameter. Run tsc and note the exact error code and message it reports.
  • Given a project that only runs on Node 18+, decide what values you’d set for target, module, and moduleResolution, and write one sentence justifying each choice.
  • Take the getLength corrected example from this lesson and rewrite it using the non-null assertion operator (value!.length) instead of the if check. Explain in your own words why this compiles but is riskier than the guarded version.

Summary

  • tsconfig.json configures the compiler via compilerOptions plus file-selection fields, and can inherit from a base config with extends.
  • strict is a bundle of independent sub-flags (noImplicitAny, strictNullChecks, strictPropertyInitialization, and more) — disabling one does not disable the others.
  • target controls emitted syntax and default globals; lib lets you decouple the assumed global APIs from the emitted syntax version.
  • module and moduleResolution control how imports are formatted and resolved, independently of type checking.
  • The compiler builds a full program from your root files, type-checks it against the configured rules, then erases all types on emit — none of this exists at runtime.
  • Prefer starting strict and staying strict; use targeted suppressions instead of loosening project-wide flags.