tsconfig & Compiler Options

Reviewed & published by Brayan K

A tsconfig.json file is the configuration that tells the TypeScript compiler which files to include and how to check and build them — controlling strictness, the output JavaScript version, the module system, and where compiled files go.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

tsconfig.json is the settings panel on a power tool. strict is the full set of safety guards — slower to get used to, but it stops you losing a finger. target is the speed dial: how modern the output runs. lib is which attachments the tool knows it has fitted. outDir/rootDir are the in-tray and out-tray that keep raw stock and finished parts separate. You configure the panel once per project, and every build afterward follows those rules — so the whole team gets the same checks and the same output.

📝 A Typical tsconfig.json (read-only)

This is JSON, not runnable code — it's the file tsc reads before compiling. Each option is annotated:

{
  "compilerOptions": {
    // --- Strictness ---
    "strict": true,              // turn on ALL the strict checks at once
    "noUnusedLocals": true,      // error on unused local variables
    "noImplicitReturns": true,   // every code path must return

    // --- Output language & APIs ---
    "target": "ES2020",          // JS version to emit
    "module": "ESNext",          // module system of the output
    "lib": ["ES2020", "DOM"],    // built-in APIs the checker knows

    // --- Files & layout ---
    "rootDir": "./src",          // where source files start
    "outDir": "./dist",          // where compiled .js goes

    // --- Interop & ergonomics ---
    "esModuleInterop": true,     // clean default imports from CommonJS
    "skipLibCheck": true,        // don't type-check .d.ts in node_modules
    "moduleResolution": "bundler", // how import paths are resolved
    "resolveJsonModule": true    // allow importing .json files
  },
  "include": ["src"],            // which files to compile
  "exclude": ["node_modules", "dist"]
}

1. Strict Mode & strictNullChecks

Setting "strict": true flips on a bundle of checks. The flagship is strictNullChecks: null and undefined stop being assignable to every type and become values you must handle explicitly. That single change eliminates a huge share of "Cannot read property of undefined" crashes:

// With strictNullChecks ON:
function getInitial(name: string | null): string {
  // return name.charAt(0);   // ❌ 'name' is possibly 'null'
  if (name === null) return "?";   // must handle it FIRST
  return name.charAt(0).toUpperCase();   // ✅ now 'name' is string
}

// strict: true also implies these (a partial list):
//   strictNullChecks, noImplicitAny, strictFunctionTypes,
//   strictPropertyInitialization, strictBindCallApply, alwaysStrict
// === strictNullChecks — the heart of "strict" mode (runtime demo) ===
// With strictNullChecks ON, TypeScript treats null/undefined as separate types
// you MUST handle before using a value. The runtime payoff is fewer
// "Cannot read property of undefined" crashes. Here's the guarding it forces:
function getInitial(name) {
  // Without a guard, name.charAt would crash if name is null/undefined.
  if (name == null) return "?";          // handle the empty case FIRST
  return name.charAt(0).toUpperCase();
}
console.log(getInitial("alice"));   // A
console.log(getInitial(null));      // ?
console.log(getInitial(undefined)); // ?

// The classic crash strict mode prevents:
function unsafeLength(text) {
  return text.length;                    // 💥 if text is undefined at runtime
}
try {
  unsafeLength(undefined);
} catch (e) {
  console.log("crash:", e.constructor.name); // crash: TypeError
}

// The safe version uses optional chaining + a default (??):
function safeLength(text) {
  return text?.length ?? 0;              // never throws
}
console.log("safe:", safeLength(undefined)); // safe: 0
console.log("safe:", safeLength("hello"));   // safe: 5

2. noImplicitAny

Also part of strict, noImplicitAny turns "I couldn't figure out this type, so I'll silently call it any" into an error. It forces you to annotate parameters the compiler can't infer, closing the door on values that quietly do the wrong thing at runtime:

// With noImplicitAny ON:
function double(x) {        // ❌ Parameter 'x' implicitly has an 'any' type
  return x * 2;
}

// Fix by annotating — now wrong calls are rejected:
function double2(x: number): number {
  return x * 2;
}
double2(5);       // ✅ 10
double2("hi");    // ❌ Argument of type 'string' is not assignable to 'number'
// === noImplicitAny — make untyped parameters an error (runtime intuition) ===
// With noImplicitAny ON, a parameter TypeScript can't infer a type for becomes
// an ERROR, forcing you to annotate it. The runtime lesson: an un-checked "any"
// lets wrong types slip through and misbehave. Watch what "any" allows:
function double(x) {            // implicitly 'any' without an annotation
  return x * 2;
}
console.log(double(5));        // 10   (intended)
console.log(double("5"));      // 10   ("5" * 2 -> number, surprising but ok)
console.log(double("hi"));     // NaN  (silent garbage — the bug noImplicitAny prevents)

// With a real type (number) the compiler would reject double("hi") up front.
// The runtime equivalent of that protection is an explicit guard:
function safeDouble(x) {
  if (typeof x !== "number") throw new TypeError("expected a number");
  return x * 2;
}
console.log("safe:", safeDouble(5));   // safe: 10
try {
  safeDouble("hi");
} catch (e) {
  console.log("rejected:", e.message);  // rejected: expected a number
}

3. target, module & lib

These three decide what your compiled output looks like. target is the JavaScript version emitted (modern syntax is kept; older targets down-level it). module is the output module system (ES modules vs CommonJS). lib is the set of built-in API type definitions the checker is allowed to assume exist:

// "target": "ES2020" keeps optional chaining as-is:
const city = user?.profile?.city;

// "target": "ES5" emits older equivalent code doing the same null checks.

// "lib": ["ES2017"] makes Object.entries known to the type-checker;
// "lib": ["DOM"]    makes document, window, fetch known.
Object.entries({ a: 1 });    // needs ES2017 in lib

// "module": "ESNext"  -> keeps import/export
// "module": "CommonJS" -> emits require()/module.exports
// === target / module / lib — what your code COMPILES DOWN TO ===
// "target" sets which JS version TypeScript emits. Modern targets (ES2020+)
// keep features like optional chaining as-is; older targets (ES5) DOWN-LEVEL
// them into equivalent older code. The runtime BEHAVIOR is the same either way.

// A modern feature: optional chaining (?.) — valid since ES2020.
const user = { profile: { city: "London" } };
console.log(user?.profile?.city);          // London
console.log(user?.address?.zip);           // undefined  (no crash)

// With target: "ES5", TS rewrites the above into older code that does the
// SAME null checks by hand. You write modern; TS emits compatible output.
// "lib" controls which built-in APIs are KNOWN to the type-checker. E.g.
// including "DOM" makes document/window typed; "ES2017" adds Object.entries:
const obj = { a: 1, b: 2 };
console.log(Object.entries(obj));          // [ [ 'a', 1 ], [ 'b', 2 ] ]

// "module" sets the module SYSTEM of the output (ESNext, CommonJS, ...).
// It decides whether import/export stay as ES modules or become require().
console.log("target = output JS version; lib = known APIs; module = import style");

🎯 Your Turn

Write the null/undefined guard that strictNullChecks would require before using a value. Fill in the blanks and match the expected output.

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// strictNullChecks forces you to handle a possibly-missing value FIRST.
// Write the guard that makes this safe for null and undefined.

function formatPrice(price) {
  // 1) If price is null OR undefined, return "Price on request"
  if (price == ___) return "Price on request";   // 👉 null  (== null also catches undefined)
  // 2) Otherwise format it with two decimals
  return "$" + price.___;                         // 👉 toFixed(2)
}

console.log(formatPrice(9.5));        // $9.50
console.log(formatPrice(null));       // Price on request
console.log(formatPrice(undefined));  // Price on request

// ✅ Expected output:
//    $9.50
//    Price on request
//    Price on request

Common Errors (and the fix)

Pro Tips

Mini-Challenge: Safe Config Reader

Write a config reader that never crashes on a missing key or a missing config — the kind of defensive code strictNullChecks pushes you toward. Follow the outline, run it, and match the example output.

// 🎯 MINI-CHALLENGE: a strict-mode-style safe config reader
// strictNullChecks would force you to handle missing keys and a missing config.
// Write readConfig(config, key, fallback) that NEVER crashes:
//   1. If config is null/undefined OR the key is missing, return the fallback.
//   2. Otherwise return config[key].
//   (Hint: optional chaining config?.[key] plus the ?? operator make this short.)
//
// Test:
//   const settings = { theme: "dark", fontSize: 14 };
//   readConfig(settings, "theme", "light")    -> "dark"
//   readConfig(settings, "language", "en")    -> "en"
//   readConfig(null, "theme", "light")        -> "light"
//   readConfig(settings, "fontSize", 12)      -> 14
//
// ✅ Example output:
//    dark
//    en
//    light
//    14

// your code here

🎉 Lesson Complete

Practice quiz

What is a tsconfig.json file?

  • A runtime config loaded by your app
  • A package manifest
  • The configuration telling the compiler which files to include and how to check/build them
  • A test runner config

Answer: The configuration telling the compiler which files to include and how to check/build them. tsconfig.json controls strictness, output JS version, module system, and file layout for tsc.

What does 'strict: true' do?

  • Enables a whole family of strict checks at once
  • Enables one check
  • Disables all checks
  • Only affects formatting

Answer: Enables a whole family of strict checks at once. It turns on strictNullChecks, noImplicitAny, strictFunctionTypes, and several more together.

What does strictNullChecks change?

  • It removes null at runtime
  • It allows null everywhere
  • It converts null to 0
  • null/undefined become separate types you must handle before use

Answer: null/undefined become separate types you must handle before use. null and undefined stop being assignable to every type, eliminating many undefined crashes.

What does noImplicitAny do?

  • Bans the any keyword entirely
  • Makes a value the compiler can't infer a type for an error
  • Adds any to everything
  • Only warns on functions

Answer: Makes a value the compiler can't infer a type for an error. It forces you to annotate values that would otherwise be silently any.

What does the 'target' option control?

  • The JavaScript language version TypeScript emits
  • The module system
  • Which files compile
  • The lib APIs

Answer: The JavaScript language version TypeScript emits. target sets the emitted JS version; older targets down-level modern syntax.

What does the 'module' option control?

  • The JS version
  • The strictness level
  • The module system of the output (ESNext, CommonJS, etc.)
  • The output folder

Answer: The module system of the output (ESNext, CommonJS, etc.). module decides whether import/export stay as ES modules or become require().

What does the 'lib' option control?

  • The output directory
  • Which built-in API type definitions the checker knows (DOM, ES2017, etc.)
  • The module system
  • The strict flags

Answer: Which built-in API type definitions the checker knows (DOM, ES2017, etc.). lib lists known APIs; e.g. DOM makes document/window typed, ES2017 adds Object.entries.

What do rootDir and outDir do?

  • Set strictness
  • Pick the target
  • Control imports
  • rootDir is where source begins; outDir is where compiled files go

Answer: rootDir is where source begins; outDir is where compiled files go. They mirror your source structure into the output folder when tsc emits files.

Why does esModuleInterop exist?

  • To speed up compilation
  • To let you write natural default imports from CommonJS modules
  • To enable strict mode
  • To freeze imports

Answer: To let you write natural default imports from CommonJS modules. It maps CommonJS module.exports cleanly so 'import React from "react"' works.

In a Vite/bundler setup, how is tsc often used?

  • To bundle the app
  • Not at all
  • With --noEmit, just for type-checking while the bundler produces output
  • To minify code

Answer: With --noEmit, just for type-checking while the bundler produces output. The bundler handles output; tsc --noEmit is run purely to type-check.

Continue this course

Frequently asked questions

What does turning on 'strict' actually do?

"strict": true is a single switch that enables a whole family of stricter checks at once, including strictNullChecks (null and undefined must be handled), noImplicitAny (every value must have a known type, not a silent any), strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict, and more. Enabling it is the strongly recommended default for new projects because each sub-flag catches a distinct class of real bugs. You can still toggle individual flags off if a specific one is impractical, but starting strict and relaxing deliberately is far safer than the reverse.

What's the difference between target, module, and lib?

They control three separate things. 'target' is the JavaScript LANGUAGE VERSION TypeScript emits (e.g. ES2020) — newer targets keep modern syntax, older ones down-level it to compatible code. 'module' is the MODULE SYSTEM of the output (ESNext, CommonJS, etc.) — it decides whether import/export stay as ES modules or become require(). 'lib' is the set of built-in API TYPE DEFINITIONS the checker knows about (e.g. DOM for document/window, ES2017 for Object.entries). Mismatching them causes 'X is not defined'-style type errors even when the runtime would actually support the feature.

What do outDir and rootDir do?

rootDir tells TypeScript where your source files begin (commonly ./src) and outDir tells it where to write the compiled .js (and .d.ts/.map) files (commonly ./dist or ./build). Together they let the compiler mirror your source folder structure into the output folder instead of scattering compiled files next to your source. They only matter when TypeScript emits files — in a Vite/bundler setup the bundler usually handles output, and tsc is often run with noEmit just for type-checking.

Why do I need esModuleInterop, and what does it fix?

Older CommonJS modules (like many npm packages) use module.exports, which doesn't map cleanly onto ES-module default imports. Without esModuleInterop you're forced to write awkward import * as React from 'react' and hit errors importing default exports from CommonJS libraries. Turning esModuleInterop on (and the related allowSyntheticDefaultImports) lets you write the natural import React from 'react' and import express from 'express', and emits small helper shims so the interop works at runtime. It's on by default in most modern setups and you almost always want it.