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 */
    "verbatimModuleSyntax": true,

    /* Language and Environment */
    "target": "ES2019",

    /* 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" #

#module docs

module controls 2 things:

  1. Supported Module Features: Whether to report the usage of certain module features as errors or not.
    E.g. import.meta.resolve, import ... with { } statements, or require(esm) from CommonJS source files.
  2. Module Emit: Whether to transform ESM import and export statements in source files to CommonJS require and module.exports in 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:

  • require() function calls, module.exports assignments, __dirname, __filename will be forbidden in ESM files; import.meta will be forbidden in CJS source files.
  • If a CJS source file is using import statements or export statements, those will be rewritten to require() function calls and module.exports assignments.

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 docs

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"] #

#types docs

In 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 like process, Buffer, Node.js-specific setTimeout overloads, 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" #

#rootDir docs

I 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, see more here).

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 code

  • dist/main.js.map: Emitted because of sourceMap. Allows to debug main.js using its source file main.ts. Also, if the runtime gets configured properly, stack traces will point to the .ts code locations instead of .js.

  • dist/main.d.ts: Emitted because of declaration. Type definitions for things exported by main.js.

  • dist/main.d.ts.map: Emitted because of declarationMap. This is a source map for the type definitions main.d.ts. One might wonder why we need a source map for type definitions (things like debugging and stack traces don't make sense for them).

    Turns out that 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.

Important notes here:

  • For most web apps we don't want TypeScript to emit anything, since bundlers (like webpack) operate on the TypeScript sources directly.
    In such cases, I don't enable any of these three options and instead enable noEmit.

  • For libraries published to npm, the decision of enabling declarationMap depends on whether we want package consumers to go to

    • the types (declarationMap not enabled)
    • or the sources (declarationMap enabled)

    ...if actions like "Go to Definition" are performed.

Enabling "verbatimModuleSyntax" #

#verbatimModuleSyntax docs

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.

This also enables isolatedModules implicitly, 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": "ES2019" #

#target docs

A 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 Chrome 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 ES2019 to access e.g. flatMap.

Enabling "incremental" #

#incremental docs

Will improve compilation times after the first run.

And the others? #

These options are recommended by the tsconfig reference, though not enabled by default:

And the "Type Checking" rules are just my personal preference: