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,
    "strict": true,

    /* Modules */
    "paths": {
      "#pkg/*": ["./src/*"]
    },
    "types": [],

    /* Emit */
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "sourceMap": true,

    /* Interop Constraints */
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,

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

    /* 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.

Path mapping "#pkg/*" pointing to the source #

#paths docs

Path aliases make imports cleaner, so it's a good idea to use them.

But why do I use #pkg/* as an alias for the source folder?
Node.js has a feature called Subpath imports which allows to "create private mappings that only apply to import specifiers from within the package itself". With them, Node.js supports path aliases natively.
No need to configure the runtime or the build system - using things like tsconfig-paths or typescript-transform-paths - anymore!

But there is one important requirement for Node.js private mappings: they must start with a # and at least one letter must follow before the slash.
I decided to go for #pkg/* because it emphasizes that an import statement using this subpath refers to a source file inside our package.

"types": [] #

#types docs

I always explicitly set "types", by default to [].

Why? The option "types" influences what types are included implicitly in the compilation process. By default, if the option is not set, TS will include all types located in the @types folder of any node_modules directory in the file system hierarchy.

This behavior can introduce issues in monorepo setups where dependencies are hoisted to the root of the monorepo. For example, if @types/node is installed in any TS package in the monorepo (and dependencies are hoisted), all TS packages in the monorepo will include those types in their compilation process!

This can lead to problems like:

  • compilation errors
  • compilation taking longer
  • pollution of global types (e.g. process being available in every package, even those not related to Node.js)
  • etc.

And here's an important detail: Types of dependencies explicitly referenced by import statements will always get included - they are not affected by this compiler option!
In recent years the JS ecosystem shifted towards using explicit imports instead of globals, so the default behavior does not provide benefits anymore.

Therefore set

  • "types": [] if no types must be added implicitly
  • "types": ["node"] in a Node.js package
  • "types": ["node", "jest"] if Jest globals are also needed

etc.

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 wonders 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 "isolatedModules" #

#isolatedModules docs

This 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 anyways; it can be disabled later in case one of those features is really needed.

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.

"target": "ES2017" #

#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 an article from web.dev and its accompanying Chrome Developers video from late 2020, ES2017 is a good choice:

  • It's supported by >95% of the browser market.
  • It includes most of the commonly used modern syntax features (e.g. async/await), resulting in significant smaller and more performant code compared to lower targets.
  • And higher targets don't change much in terms of size and performance.

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: