TypeScript tsconfig.json

Every real TypeScript project has a file called tsconfig.json sitting at its root. It tells the TypeScript compiler (tsc) two things: which files belong to your project, and exactly how strictly and to what JavaScript those files should be checked and compiled. Get it wrong and you either miss real bugs, ship code that breaks on your target runtime, or fight confusing errors about files that “shouldn’t” be part of your project. This lesson covers the file in full: its structure, the options that matter most, how the compiler actually resolves it, and the mistakes almost everyone makes at least once.

Overview: How tsconfig.json Works

When you run tsc with no arguments, it doesn’t compile a single file — it looks in the current directory, and then upward through parent directories, for a file named tsconfig.json. The directory containing that file becomes the root of the TypeScript project. Once found, tsconfig.json answers two separate questions for the compiler:

  • Which files are in scope? Controlled by include, exclude, and files.
  • How should they be checked and emitted? Controlled by the compilerOptions object — things like how strict the type checker is, which JavaScript version to target, and where output goes.

A useful mental model: tsconfig.json never changes what your code does at runtime. It only changes what the type checker is willing to accept, and what JavaScript syntax the compiler is allowed to emit. Type annotations, interfaces, and generics are all erased during emit regardless of your configuration — tsconfig.json just decides how picky the compiler is before that erasure happens, and what dialect of JavaScript comes out the other end.

One detail that surprises people coming from strict JSON backgrounds: tsconfig.json is not parsed as strict JSON. TypeScript uses a lenient, JSON5-like parser for it, which is why you’ll commonly see // comments and trailing commas in real-world tsconfig.json files even though a plain JSON.parse call would reject them.

Syntax

The general shape of a tsconfig.json file looks like this:

{
  "extends": "./base.tsconfig.json",
  "compilerOptions": {
    // type-checking and emit settings go here
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"],
  "files": ["src/main.ts"],
  "references": [{ "path": "../shared" }]
}
Top-level key Purpose
extends Inherits settings from another config file, so multiple packages can share one base configuration.
compilerOptions The bulk of the file — controls type-checking strictness, target JS version, module system, output location, and more.
include Glob patterns for files to add to the project (e.g. "src/**/*").
exclude Glob patterns to remove from what include matched. Defaults to ["node_modules", "bower_components", "jspm_packages"] plus the outDir.
files An explicit list of individual file paths, useful for small projects with no globbing.
references Points to other TypeScript projects for project references / composite, incremental builds.

Inside compilerOptions, the options you’ll reach for constantly are:

Option What it does
target The ECMAScript version to compile down to (e.g. "ES2020"). Newer syntax is down-leveled if the target is older.
module The module system to emit ("commonjs", "ESNext", "NodeNext", etc.).
lib Which built-in type declarations are available to the checker (e.g. "DOM", "ES2020"). Independent of target.
strict Enables the full family of strict type-checking flags (see below) in one switch.
outDir / rootDir Where compiled output goes, and which folder is treated as the source root.
esModuleInterop Allows default-import syntax for CommonJS modules that don’t have a real default export.
skipLibCheck Skips type-checking of .d.ts files (mainly in node_modules), speeding up builds.
declaration Emits matching .d.ts files alongside the compiled JavaScript.
baseUrl / paths Configure non-relative module resolution and import aliases.

Examples

Example 1: A standard project layout

A typical Node-targeted project keeps sources in src/ and compiles to dist/:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
interface User {
  id: number;
  name: string;
}

function greet(user: User): string {
  return `Hello, ${user.name}! Your ID is ${user.id}.`;
}

const user: User = { id: 1, name: "Ava" };
console.log(greet(user));

Output:

Hello, Ava! Your ID is 1.

Nothing about this output depends on tsconfig.json — but the fact that it compiled cleanly does. include tells tsc to pick up every file under src, rootDir/outDir keep source and build output separated, and strict is what would have caught it if user were missing a required field.

Example 2: strict mode changing what the checker accepts

With "strict": true, strictNullChecks is turned on, which means anything that can be undefined — like the result of Array.prototype.find — must be handled before use:

function findUser(id: number, users: { id: number; name: string }[]): string {
  const found = users.find(u => u.id === id);
  if (!found) {
    return "Unknown user";
  }
  return found.name;
}

const users = [
  { id: 1, name: "Ava" },
  { id: 2, name: "Ben" }
];
console.log(findUser(2, users));
console.log(findUser(5, users));

Output:

Ben
Unknown user

Without strictNullChecks, found.name would type-check even if you forgot the if (!found) guard, and it would crash at runtime with “Cannot read properties of undefined” the first time a lookup failed. The tsconfig.json setting is what forces you to handle that case at compile time instead.

Example 3: target and lib enabling modern syntax

Optional chaining (?.) and nullish coalescing (??) are ES2020 features. They need a target/lib combination that recognizes ES2020 syntax and globals:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "strict": true
  }
}
interface Config {
  timeout?: number;
  retries?: {
    max?: number;
  };
}

function getMaxRetries(config: Config): number {
  return config.retries?.max ?? 3;
}

console.log(getMaxRetries({}));
console.log(getMaxRetries({ retries: { max: 5 } }));

Output:

3
5

config.retries?.max short-circuits to undefined if retries is missing, and ?? 3 supplies the fallback only when the left side is null or undefined (unlike ||, which would also replace a valid 0). target controls what syntax is allowed to appear in the emitted JavaScript; lib separately controls which global APIs the type checker knows about.

Under the Hood: How tsc Resolves Your Config

When tsc starts, it performs a few distinct steps before checking a single line of your code:

  • Discovery. Starting from the current directory (or the path passed to --project), tsc walks upward looking for tsconfig.json. The first one found wins.
  • Extends resolution. If extends is present, the parent config is loaded first, then the child’s compilerOptions are shallow-merged on top of it. Crucially, include, exclude, and files are not merged — if the child defines its own, it fully replaces the parent’s rather than adding to it. This trips up a lot of monorepo setups.
  • File enumeration. include globs are expanded, then anything matching exclude is removed, then anything listed in files is added explicitly regardless of the other two.
  • Flag expansion. Shorthand flags are expanded into their real effects. "strict": true, for example, is not one flag — it flips on noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables together. You can still override any one of them individually below "strict": true.
  • Type-checking. The checker walks the program using the effective flags — this is purely compile-time analysis based on inferred and declared types; it never runs your code.
  • Emit. If noEmit isn’t set, JavaScript is written out according to target (syntax level) and module (module system), with all type annotations, interfaces, and generic parameters stripped completely. The emitted .js file carries zero trace of the type system — tsconfig.json only ever influenced how that JavaScript was validated and shaped before the types were thrown away.

Common Mistakes

Mistake 1: Skipping strict mode and losing implicit-any safety

Without strict, TypeScript happily accepts untyped parameters as any, silently disabling most of its own value:

// tsconfig.json — strict mode missing
{
  "compilerOptions": {
    "target": "ES2020"
  }
}
function double(x) {
  return x * 2;
}
console.log(double("4"));

This compiles fine and prints NaN at runtime because x is implicitly typed any, so a string sneaks past the multiplication. Turn on strict and tsc immediately reports: Parameter 'x' implicitly has an 'any' type. The fix is to enable strict mode and give the parameter a real type:

function double(x: number): number {
  return x * 2;
}
console.log(double(4));

Output:

8

Mistake 2: Using DOM globals without the “DOM” lib

If lib is set explicitly and "DOM" is left out, browser globals disappear from the type checker’s vocabulary:

// tsconfig.json — DOM lib missing
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"]
  }
}
const el = document.querySelector("#app");
console.log(el?.textContent);

This fails with Cannot find name 'document'. — not because document doesn’t exist in a browser, but because the checker was never told about it. Adding "DOM" back to lib fixes it:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"]
  }
}
const el = document.querySelector("#app");
console.log(el?.textContent ?? "no element found");

Output:

no element found

Note that lib only affects what the checker knows exists — it does not add or remove any actual runtime capability, and this snippet is only meant to be type-checked, not executed in Node (there is no real document there).

Best Practices

  • Turn on "strict": true on every new project — it’s far harder to retrofit onto a large existing codebase later.
  • Use "skipLibCheck": true so type-checking third-party .d.ts files in node_modules doesn’t slow down every build.
  • Keep rootDir and outDir distinct so compiled output never mixes with source files or gets accidentally re-included.
  • Scope include/exclude deliberately rather than relying on defaults, especially in monorepos with multiple packages.
  • Remember that extends merges compilerOptions but replaces include/exclude/files — repeat what you need in the child config.
  • Match target and lib to the runtime you actually deploy to, not the newest possible option.
  • Enable "forceConsistentCasingInFileNames": true to catch import-casing bugs before they break a Linux CI server.
  • For large multi-package repositories, use "composite": true with references for faster, incremental project builds.

Practice Exercises

  • Write a tsconfig.json for a Node 18 project: CommonJS modules, output to dist from a src root, strict mode on, and source maps enabled. Then write a tiny function with a missing parameter type and note the exact tsc error it produces before you fix it.
  • Create a base config with "include": ["src/**/*"] and a child config that extends it while also declaring its own "include": ["test/**/*"]. Predict which folders end up in the compiled project, then check whether your prediction matches how extends actually merges (or doesn’t merge) array options.
  • Enable "noUnusedParameters": true in a config, then write a function with a parameter it never uses. Note the exact error tsc reports, then fix it two different ways: removing the parameter, and prefixing it with an underscore.

Summary

  • tsconfig.json marks a project root and controls both which files are compiled and how strictly they’re checked.
  • include/exclude/files decide file scope; compilerOptions decides checking behavior and emit shape.
  • "strict": true is shorthand for several individual flags like noImplicitAny and strictNullChecks — enable it from day one.
  • target controls the emitted JavaScript syntax level; lib separately controls which global APIs the type checker recognizes.
  • extends shares a base config across projects, but merges compilerOptions only — array options like include are replaced, not combined.
  • All of this is compile-time only: types, interfaces, and generics are fully erased at emit, leaving plain JavaScript with no runtime trace of the configuration that produced it.