tsconfig.json Explained: The Most Important Options in Detail
AI generated
<T>
type
TypeScript · tsconfig.json · Compiler Options · Build Tooling
tsconfig.json Explained
The Most Important Options in Detail

tsconfig.json is the central control panel of every TypeScript project and determines compatibility, strictness, and build speed. This article walks through the most impactful options such as target, module, strict, esModuleInterop, and skipLibCheck, provides a sensible baseline configuration for a new project, and shows how extends lets teams share configuration cleanly across a monorepo.

12 min read target · module · strict · extends TypeScript 5.x · Node.js · Monorepo

1. Why tsconfig.json is the most important configuration file

Running tsc --init gives you a tsconfig.json with over a hundred commented-out options and no guidance on which of them actually matter. Yet this single file decides three fundamental things: what JavaScript actually comes out of the compiler, how strictly TypeScript catches errors in your own code, and how the compiler treats modules, libraries, and third party types. A misconfigured target produces code that either crashes in older environments or ships needlessly bloated. A misconfigured module produces imports that the target runtime cannot resolve at all.

For Magento and Hyvä developers using TypeScript for build scripts, admin tools, or headless frontend integrations, tsconfig.json is often the only place where project wide decisions are made without touching every single file. The sections below walk through the most important options one by one, present a sensible baseline configuration for new projects, and explain how to share configuration across multiple packages in a monorepo with extends.

2. target and lib: what JavaScript actually comes out

target determines which ECMAScript version the TypeScript compiler downlevels the code to, making it the single option with the largest influence on the generated output. With ES5, arrow functions, classes, async/await, and optional chaining all get translated into older, noticeably longer code, which measurably increases bundle size and execution time. With a modern target like ES2022, the syntax largely stays intact, because current Node.js versions and every relevant browser support these features natively. The rule of thumb: pick the highest target you can, based on the project's actual runtime environments rather than an imaginary worst case.

lib is independent of target and determines which global type declarations the compiler is aware of, such as Promise, fetch, or DOM interfaces like HTMLElement. A Node.js project with no browser code doesn't need DOM in lib, while a frontend project does. If a required lib is missing, the compiler reports unknown types even though the code runs perfectly fine at runtime, because the runtime environment already provides the API.


# Compile the same source file with different target settings
$ tsc --target ES5 --module CommonJS greet.ts
$ cat greet.js
"use strict";
var greet = function (name) {
    return "Hello, " + name;
};

# ES2022 target keeps modern syntax, smaller and closer to source
$ tsc --target ES2022 --module ESNext greet.ts
$ cat greet.js
const greet = (name) => `Hello, ${name}`;

# lib controls which global runtime APIs the compiler assumes exist
$ tsc --target ES2022 --lib ES2022,DOM greet.ts

3. module and moduleResolution: the basics

module determines which module system the compiler emits, for example CommonJS with require/module.exports or ESNext with native import/export statements. For Node.js projects that consume both ESM and CommonJS packages, NodeNext is the right choice, because the compiler then decides which format is expected based on the file extension and the type field in package.json.

moduleResolution controls the algorithm the compiler uses to resolve imports like import { foo } from "./bar" to actual files. The options Node10, NodeNext, and Bundler differ mainly in whether relative imports require file extensions and how package.json exports fields get interpreted. This article only briefly touches on the finer differences in module resolution; a dedicated article on TypeScript module resolution covers the topic with every edge case in detail. For everyday practice, the rule of thumb is enough: NodeNext for Node.js libraries and CLI tools, Bundler for applications bundled by Vite, esbuild, or Webpack.

4. The strict family: which flags actually matter

strict is not a single flag, but an umbrella switch that turns on a whole group of stricter checks at once, including strictNullChecks, noImplicitAny, strictFunctionTypes, and strictPropertyInitialization. Without strictNullChecks, TypeScript treats null and undefined as subtypes of every other type, which means the most common runtime error class in JavaScript, accessing a property on undefined, stays invisible at compile time. noImplicitAny prevents parameters without a type annotation from silently becoming any, which would otherwise disable type checking for that value entirely.

In existing projects, it's worth introducing strict incrementally: first noImplicitAny, then strictNullChecks, because these two flags surface the majority of errors in untyped legacy code. New projects should set strict: true from day one, because retrofitting it into a growing codebase becomes exponentially more expensive than maintaining it from the start.


// strict: false - these all compile without any error
function getUser(id) {
  const user = users.find(u => u.id === id);
  return user.name; // user might be undefined, no warning at all
}

let value;
value = 42;
value = "now a string"; // implicit any, TypeScript does not complain

// strict: true - TypeScript forces you to handle the edge cases
function getUser(id: number): string {
  const user = users.find(u => u.id === id);
  if (!user) {
    throw new Error(`User ${id} not found`);
  }
  return user.name; // narrowed to defined, this access is safe
}

let typedValue: number;
typedValue = 42;
// typedValue = "now a string";
// Error: Type 'string' is not assignable to type 'number'.

5. esModuleInterop and skipLibCheck: two underrated switches

esModuleInterop resolves a historical compatibility problem between CommonJS and ES modules: without this option, TypeScript forces the default import of a CommonJS package into a syntax like import * as express from "express", even though most libraries actually expect a real default export. With esModuleInterop: true, the more natural import express from "express" works reliably, because the compiler automatically generates a compatible wrapper at compile time. This option should be enabled in practically every new project, unless it's a pure ESM library with no CommonJS dependencies at all.

skipLibCheck skips type checking inside every .d.ts file, meaning the type declarations shipped inside node_modules. Without this option, the compiler also checks for type conflicts between different versions of the same library across nested dependencies, which noticeably slows down builds in large projects and reports errors that an application developer has no control over anyway. skipLibCheck: true is the right choice in nearly every project and speeds up the build noticeably, without reducing type safety in your own code.

6. outDir, rootDir, and include/exclude: project structure

rootDir defines which directory acts as the common root of all input files, and thereby determines how the folder structure gets mirrored inside the output directory. outDir defines where the compiled JavaScript code gets written. Without an explicit rootDir, the compiler computes the common root automatically from all included files, which can produce an unexpectedly deep folder structure in the output when test files live outside of src/.

include and exclude control which files are actually part of the compilation at all. include accepts glob patterns like src/**/*, while exclude explicitly leaves out patterns like node_modules, dist, or test files. Important detail: exclude does not prevent a file from being imported, only from being compiled as a standalone entry. An excluded file can still get pulled into the compilation through an import if an included file references it.


project/
├── src/
│   ├── index.ts
│   ├── utils/
│   │   └── format.ts
│   └── components/
│       └── header.ts
├── dist/            # generated by outDir, mirrors the src structure
│   ├── index.js
│   ├── utils/
│   │   └── format.js
│   └── components/
│       └── header.js
└── tsconfig.json

# tsconfig.json excerpt
# "rootDir": "./src"   -> only src/ is treated as the input root
# "outDir": "./dist"   -> compiled output mirrors src/ under dist/
# "include": ["src/**/*"]
# "exclude": ["node_modules", "dist", "**/*.test.ts"]

$ tsc
# emits dist/index.js, dist/utils/format.js, dist/components/header.js

7. A sensible baseline tsconfig.json for a new project

For most new TypeScript projects, whether a Node.js backend, a build script, or a headless frontend integration, the following baseline configuration is a solid starting point. It combines modern target semantics with maximum error strictness, without dragging along unnecessary compatibility baggage from the past.


{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

This configuration turns on strict plus several additional checks such as noUncheckedIndexedAccess, which treats array and object access through an index as possibly undefined by default. declaration and declarationMap generate .d.ts files with sourcemap support, which matters for any package that other TypeScript projects will import. Starting from this baseline and adapting it to the specific project saves the painful work of retrofitting strict checks into an already grown codebase later.

8. extends: sharing config across a monorepo

In a monorepo with multiple packages, say an API server, a shared utility library, and a frontend, the same baseline configuration ends up duplicated in every single package without extends. The extends field solves this by letting a package specific tsconfig.json reference a shared base file and override only the values that genuinely differ, such as rootDir, outDir, or lib. Every other option is inherited unchanged from the base file.

For monorepos with build dependencies between packages, it's also worth combining composite: true with references, because TypeScript then supports incremental builds across package boundaries: if only one package changes, dependent packages aren't fully recompiled from scratch. One important detail when using extends: relative paths such as include and exclude in the overriding file resolve relative to that overriding file, not relative to the base file, a common gotcha in deeply nested package structures.


// tsconfig.base.json at the monorepo root, shared by every package
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "composite": true
  }
}

// packages/api/tsconfig.json extends the shared base file
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "lib": ["ES2022"]
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../shared" }
  ]
}

9. tsconfig.json options compared side by side

Not every TypeScript default is the right choice for a modern project. The table below shows five options where the risky or outdated setting still shows up frequently in existing projects, alongside the recommended alternative for new or modernized configurations.

Option Risky / outdated Recommended Effect
target ES5 ES2022 Smaller, modern output without needless transpilation
module CommonJS NodeNext / ESNext Native ESM interop, future proof for Node.js
strict false true null/undefined errors at compile time instead of runtime
skipLibCheck false true Noticeably faster builds, no checking of third party .d.ts files
moduleResolution node (Node10) bundler Correct exports field resolution for Vite/esbuild projects

The common thread across all five rows: the riskier option was usually the only practical choice years ago, for example back when Node.js itself had no native ESM support. Anyone setting up a new project today should consistently reach for the recommended values and only deviate when there's a demonstrable need, such as supporting very old browsers or legacy build pipelines.

Mironsoft

TypeScript tooling, build configuration, and headless integrations for Magento

Is your tsconfig.json set up right, or just copied?

We set up TypeScript projects, build scripts, and monorepos for your Magento and Hyvä environment properly, from a sensible baseline configuration to shared extends setups across multiple packages.

tsconfig audit

Review the existing configuration and flag risky defaults

Monorepo setup

Shared baseline configuration with extends and project references

Build tooling

Cleanly integrating TypeScript into Vite, esbuild, and CI pipelines

10. Summary

tsconfig.json is not a random collection of compiler switches, but the central place where a TypeScript project defines its target environment, its error strictness, and its module strategy. target and lib determine what JavaScript comes out and which runtime APIs are known. module and moduleResolution decide how imports get resolved. The strict family catches the most common runtime errors already at compile time, esModuleInterop and skipLibCheck solve two practical compatibility and performance problems, and outDir/rootDir keep the output cleanly structured.

For new projects, it's worth adopting the baseline configuration shown in this article as a starting point and working with strict from day one, rather than retrofitting it later. In monorepos, extends drastically reduces duplication and ensures every package shares the same fundamental compiler rules, while project specific details like rootDir and outDir can still be overridden locally.

tsconfig.json Explained - The Essentials at a Glance

target & lib

Pick a modern target like ES2022 and set lib to match the actual runtime environment.

strict family

strict: true from day one in new projects, incremental migration in legacy projects.

esModuleInterop & skipLibCheck

Enable almost always, for clean default imports and noticeably faster builds.

extends in a monorepo

A shared base file with package specific overrides for rootDir and outDir.

11. FAQ: tsconfig.json Explained

1What is tsconfig.json and do I actually need one?
The central configuration file of a TypeScript project. Defines which files get compiled, what JavaScript comes out, and how strictly errors get reported. Practically indispensable past a single file.
2What does target actually control?
The compiler's ECMAScript target version. Low targets like ES5 produce longer, older code. Modern targets like ES2022 keep the syntax largely intact.
3What's the difference between module and moduleResolution?
module determines the output format, moduleResolution the algorithm for resolving imports to files. Related but independent options.
4What does strict: true actually enable?
An umbrella switch for strictNullChecks, noImplicitAny, strictFunctionTypes, and more. Catches the most common runtime errors already at compile time.
5Why does esModuleInterop matter?
Allows natural default imports for CommonJS packages via an automatically generated wrapper. Without it, more cumbersome namespace imports are required.
6What does skipLibCheck do and is it safe?
Skips type checking of .d.ts files in node_modules. Speeds up builds noticeably and is safe in nearly every project.
7What's the difference between include/exclude and files?
include/exclude use glob patterns for whole directories. files explicitly lists individual files, mostly useful for very small projects.
8How do outDir and rootDir work together?
rootDir defines the root of all input files, outDir the target directory. The compiler mirrors the structure below rootDir one to one inside outDir.
9How does extends work in a monorepo?
A package specific tsconfig.json references a shared base file and overrides only the values that differ. Every other option is inherited unchanged.
10Can a project have multiple tsconfig.json files?
Yes, larger projects often use multiple files for different purposes, such as build and tests, usually linked together via extends.