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
- Read the structure of a tsconfig.json file
- Enable strict mode and know which checks it turns on
- Understand strictNullChecks and noImplicitAny by their runtime payoff
- Set target, module, and lib correctly
- Use outDir and rootDir to organise compiled output
- Know why esModuleInterop and other common flags exist
💡 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: 52. 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 requestCommon Errors (and the fix)
- ❌ "Object is possibly 'null'/'undefined'": strictNullChecks caught an unguarded access. ✅ Check first (if (x != null)) or use x?.prop / x ?? fallback.
- ❌ "Parameter 'x' implicitly has an 'any' type": noImplicitAny wants an annotation. ✅ Add the type, or use unknown if it's truly unknown.
- ❌ "Cannot find name 'document'": the DOM lib isn't in lib. ✅ Add "lib": ["ES2020", "DOM"] for browser code.
- ❌ "Property 'entries' does not exist on 'ObjectConstructor'": your target/lib is too old for Object.entries. ✅ Raise target or add "ES2017" to lib.
- ❌ "Module can only be default-imported using esModuleInterop": importing a CommonJS default without interop. ✅ Set "esModuleInterop": true.
Pro Tips
- 💡 Run tsc --init to generate a config with every option documented inline — a great reference.
- 💡 Keep strict on. Relax individual sub-flags only when you have a concrete reason, never the whole switch.
- 💡 In bundler setups (Vite, etc.) you often run tsc --noEmit just for type-checking and let the bundler produce the output.
- 💡 Match target to your runtime. Modern Node and evergreen browsers support ES2020+, so you rarely need to down-level to ES5.
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
- ✅ tsconfig.json configures which files compile and how they're checked and built
- ✅ "strict": true enables a family of checks, headlined by strictNullChecks
- ✅ strictNullChecks forces you to handle null/undefined before use
- ✅ noImplicitAny rejects values the compiler can't type, ending silent any
- ✅ target/module/lib set the output JS version, module system, and known APIs
- ✅ outDir/rootDir organise output; esModuleInterop smooths CommonJS imports
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
- Previous: Modules & Declaration Files
- Next: Checkpoint: Advanced Types — Combine mapped types, discriminated unions and generics — then a quiz
- Quick reference: TypeScript cheat sheet
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.