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.
Table of Contents
- 1. Why tsconfig.json is the most important configuration file
- 2. target and lib: what JavaScript actually comes out
- 3. module and moduleResolution: the basics
- 4. The strict family: which flags actually matter
- 5. esModuleInterop and skipLibCheck: two underrated switches
- 6. outDir, rootDir, and include/exclude: project structure
- 7. A sensible baseline tsconfig.json for a new project
- 8. extends: sharing config across a monorepo
- 9. tsconfig.json options compared side by side
- 10. Summary
- 11. FAQ
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.