Sensible tsconfig.json Defaults
When I set up a TypeScript package, this is the tsconfig.json configuration I start from:
{
"compilerOptions": {
/* Type Checking */
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
/* Modules */
"module": "Node20",
"moduleResolution": "Node16",
"rootDir": "./src",
"types": ["node"],
/* Emit */
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"sourceMap": true,
/* Interop Constraints */
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
/* Language and Environment */
"target": "ES2020",
/* Projects */
"incremental": true,
/* Completeness */
"skipLibCheck": true,
},
"include": ["src/**/*"],
"exclude": ["**/node_modules"],
}
I use it for new packages and adjust it as needed.
Below you find an explanation why I think these compiler options represent good defaults.
"module": "Node20" #
docsmodule controls 2 things:
- Supported Module Features: Whether to report the usage of certain module features as errors or not.
E.g.import.meta.resolve,import ... with { }statements, orrequire(esm)from CommonJS source files. - Module Emit: Whether to transform ESM
importandexportstatements in source files to CommonJSrequireandmodule.exportsin output files.
"Node20" models the behavior of Node.js 20+.
Each TypeScript source file is treated as ESM or CJS based on type of the "nearest" package.json file (that is, in the same directory or in any ancestor directory of the TypeScript source file).
The detected module format (ESM or CJS) determines the supported module features and module emit:
- ESM-exclusive features like
import ... with { }statements andimport.metawill be forbidden in CJS source files. - If a CJS source file is using
importstatements orexportstatements, those will be rewritten torequire()function calls andmodule.exportsassignments.
Dynamic import()s are always kept as-is even if the output target of a file is CJS (in the old CommonJS setting, it would be replaced by require(), which is unnecessary as Node.js CJS modules support dynamic import()s).
If you want to use any module features only supported by a newer "module": "NodeXX" value, upgrade to it but also make sure that the code runs on that Node.js version (or above).
"moduleResolution": "Node16" #
moduleResolution controls how TypeScript resolves a module specifier to an actual file on the disk.
The module specifier is the string when loading modules:
import ... from "specifier"export ... from "specifier"await import("specifier")require("specifier")
Different hosts (Node.js, bundlers) follow different resolution rules: whether package.json#exports and #imports are consulted, whether extensionless relative imports (like "./utils" instead of "./utils.js") are allowed, and whether directory imports (like "./components" resolving to "./components/index.js") are allowed.
"Node16" resolves package.json#exports and #imports and forbids extensionless and directory imports in ESM files, matching Node.js behavior.
"types": ["node"] #
docsIn TypeScript 6.0, types defaults to [] (an empty array).
This means TypeScript will no longer implicitly include all @types packages from node_modules.
You still need to add entries if you rely on globally available types:
"types": ["node"]in a Node.js package (for globals likeprocess,Buffer, Node.js-specificsetTimeoutoverloads, etc.)"types": ["node", "jest"]if Jest globals are also needed
etc.
Alternatively, you can avoid the types entry entirely by using explicit imports (e.g. import process from 'node:process'), which are always resolved regardless of this option.
"rootDir": "./src" #
docsI always explicitly set rootDir, by default to "./src".
rootDir controls the directory structure of your output files relative to the output directory.
In TypeScript 6.0, the default rootDir will always be the directory containing the tsconfig.json file (except when using tsc from the command line without a tsconfig.json file, then rootDir will be inferred, more infos ).
Since I usually put my source files in a src directory, I set rootDir to "./src" to ensure that the output files in dist have the same directory structure as the source files in src.
Enabling "sourceMap", "declaration" and "declarationMap" #
Given a source file src/main.ts, this configuration will emit the following files:
dist/main.js: the JavaScript codedist/main.js.map: Emitted because of . Allows to debugmain.jsusing its source filemain.ts. Also, if the runtime gets configured properly, stack traces will point to the.tscode locations instead of.js.dist/main.d.ts: Emitted because of . Type definitions for things exported bymain.js.dist/main.d.ts.map: Emitted because of . This is a source map for the type definitionsmain.d.ts.
IDEs (e.g. VS Code) can use such declaration maps to improve code navigation - things like "Go to Definition", "Find all References" etc.
This is very useful for monorepos because it improves the cross-package editing experience.
For libraries published to npm, the decision of enabling declarationMap depends on whether we want package consumers to go to
- the types (
declarationMapnot enabled) - or the sources (
declarationMapenabled)
...if actions like "Go to Definition" are performed.
Enabling "erasableSyntaxOnly" #
erasableSyntaxOnly rejects TypeScript features that need code generation, like enum, parameter properties, runtime namespaces, import = require(...), and export =.
This matches modern Node.js very well, because Node.js's built-in TypeScript support only works for syntax that can be handled by stripping types.
In practice, this nudges Node.js projects toward a smaller, more JavaScript-native, ESM-first subset of TypeScript.
Enabling "verbatimModuleSyntax" #
This enables the restriction for import statements that import type must be used if the imported thing is used as a type only (and not used as a value).
Such imports will get fully erased when TypeScript compiles the sources to JavaScript.
Also, it can help tools like bundlers to determine whether an import is needed at runtime or not.
The auto-import feature of VS Code will automatically use import type statements when this compiler option is set, making it less cumbersome to follow this import restriction.
It also aligns well with modern Node.js.
When Node.js runs .ts files by stripping types, type imports must be written explicitly with the type keyword.
That is why the TypeScript docs recommend this option.
verbatimModuleSyntax also enables isolatedModules, which forbids the use of a few TypeScript features that are not supported by tools which operate on a single-file basis, like Babel.
Affected are e.g. namespace's and const enum's.
I think it is a good idea to start with this option enabled, as I feel we don't need the affected features often anyway; it can be disabled later in case one of those features is really needed.
"target": "ES2020" #
docsA lower target means the emitted JavaScript is supported by more browsers/runtimes;
a higher target will lead to smaller and more performant output because modern ECMAScript features (like async/await) do not need to be downleveled to more verbose code.
So what target is the best tradeoff here?
According to a Developers video from late 2020, ES2017 was a good choice back then:
- It was supported by >95% of the browser market.
- It included most of the commonly used modern syntax features (e.g.
async/await), resulting in significantly smaller and more performant code compared to lower targets. - And higher targets didn't change much in terms of size and performance.
Even in 2026 it still applies that targets newer than ES2017 don't change much in terms of size and performance.
So my advice is to bump the target whenever you want access to newer JavaScript features.
I bumped it to ES2020 to have access to e.g. flatMap.
Enabling "incremental" #
docsWill improve compilation times after the first run.
And the others? #
These options are recommended by the reference, though not enabled by default:
And the "Type Checking" rules are just my personal preference: