A checklist for your tsconfig.json

[2025-01-15] dev, typescript
(Ad, please don’t block)

Version: TypeScript 5.7


In order to feel more confident about my tsconfig.json, I decided to go through the tsconfig.json documentation, collect all commonly used options and describe them below:

  • You can come along for the whole ride and go through the options with me. Afterward, you should be able to understand all of your tsconfig.
  • Or you can skip ahead to the summary at the end.
  • I also list recommendations for tsconfig.json by several other people.

I’m curious what your experiences with tsconfig.json are: Do you agree with my recommendations? Did I miss anything?

Features not covered by this blog post  

This blog post only describes how to set up projects whose local modules are all ESM. It does give tips for importing CommonJS, though.

Not explained here:

Notation  

For showing inferred types in the source code, I use ts-expect – e.g.:

// Check that the inferred type of `someVariable` is `boolean`
expectType<boolean>(someVariable);

I’m often using trailing commas in my JSON because that’s supported for tsconfig.json and because it helps with rearranging, copying, etc.

Extending base files via extends  

This option lets us refer to an existing tsconfig.json via a module specifier (as if we imported a JSON file). That file becomes the base that our tsconfig extends. That means that our tsconfig has all the option of the base, but can override any of them and can add options not mentioned in the base.

The GitHub repository tsconfig/bases lists bases that are available under the npm namespace @tsconfig and can be used like this (after they were installed locally via npm):

{
  "extends": "@tsconfig/node-lts/tsconfig.json",
}

Alas, none of these files suits my needs. But they can serve as an inspiration for your tsconfig.

Where are the input files?  

{
  "include": ["src/**/*", "test/**/*"],
}

On one hand, we have to tell TypeScript what the input files are. These are the available options:

  • files: an exhaustive array of all input files
  • include: Specifies the input files via an array of patterns with wildcards that are interpreted as relative to tsconfig.json.
  • exlude: Specifies which files should be excluded from the include set of files – via an array of patterns.

What is the output?  

Where are the output files written?  

"compilerOptions": {
  "rootDir": ".",
  "outDir": "dist",
}

How TypeScript determines where to write an output file:

  • It takes the input path (relative to tsconfig.json),
  • removes the prefix specified by rootDir and
  • “appends” the result to outDir.

The default value of rootDir is the longest common prefix of the relative paths of the input files.

As an example, consider the following tsconfig.json:

{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist",
  }
}

This is the file structure of a project:

/tmp/my-proj/
  tsconfig.json
  src/
    main.ts
  test/
    test.ts

The TypeScript compiler produces this output:

/tmp/my-proj/
  dist/
    src/
      main.js
    test/
      test.js

If we remove rootDir from tsconfig.json then the output is the same because its default value is ".".

However, the output is different if we also change include:

{
  "include": ["src/**/*"],
  "compilerOptions": {
    "outDir": "dist",
  }
}

Now the default value of rootDir is src and the output is:

/tmp/my-proj/
  dist/
    main.js

Because the default value of rootDir changes depending on include, I like to specify it explicitly in my tsconfig.json. But you can omit it, if you are happy with how the default works.

Emitting source maps  

"compilerOptions": {
  "sourceMap": true,
}

sourceMap produces source map files that point from the transpiled JavaScript to the original TypeScript. That helps with debugging and is usually a good idea.

Emitting .d.ts files (e.g. for libraries)  

If we want TypeScript code to consume our transpiled TypeScript code, we usually should include .d.ts files:

"compilerOptions": {
  "declaration": true,
  "declarationMap": true, // enables importers to jump to source
}

Optionally, we can include the TypeScript source code in our npm package and activate declarationMap. The importers can, e.g., click on types or go to the definition of a value and their editor will send them to the original source code.

Option declarationDir  

By default, each .d.ts file is put next to its .js file. If you want to change that, you can use option declarationDir.

Fine-tuning emitted files  

"compilerOptions": {
  "newLine": "lf",
  "removeComments": false,
}

The values shown above are the defaults.

  • newLine configures the line endings for emitted files. Allowed values are:
    • "lf": "\n" (Unix)
    • "crlf": "\r\n" (Windows)
  • removeComments: If active, all comments in TypeScript files are omitted in transpiled JavaScript files. I’m weakly in favor of sticking with the default and not removing comments:
    • It helps with reading transpiled JavaScript – especially if the TypeScript source code isn’t included.
    • Bundlers remove comments.
    • On Node.js, the added burden doesn’t matter much.

Language and platform features  

"compilerOptions": {
  "target": "ES2024",
  // Omit if you want to use the DOM
  "lib": [ "ES2024" ],
}

target  

target determines which newer JavaScript syntax is transpiled to older syntax. For example, if the target is "ES5" then the arrow function () => {} is transpiled to the function expression function () {}.

  • We can look up recommended settings for various platforms in the GitHub repo tsconfig/bases.
  • The value "ESNext" means “the highest version supported by the installed TypeScript”. Since that changes between TypeScript versions, it may cause problems when we upgrade.

I’m wondering if there should be an option to never transpile JavaScript features. On the other hand, being able to write modern JavaScript on potentially older browsers is quite convenient.

How to pick a good target  

We have to pick an ECMAScript version that works for our target platforms. There are two tables that provide good overviews:

We can also check out the official tsconfig bases which all provide values for target.

lib  

lib determines which types for built-in APIs are available – e.g. Math or methods of built-in types.

  • The TypeScript documentation describes what values can be added to the array. A full list of them can be looked up in the TypeScript repository.

  • The values are case-insensitive.

  • There are categories such as "ES2024" and "DOM" and subcategories such as "DOM.Iterable" and "ES2024.Promise".

  • target determines the default value of lib: If the latter is omitted and target is "ES20YY" then "ES20YY.Full" is used. However, that is not a value we can use ourselves. If we want to replicate what removing lib does, we have to enumerate the contents of (e.g.) es2024.full.d.ts ourselves:

    /// <reference lib="es2024" />
    /// <reference lib="dom" />
    /// <reference lib="webworker.importscripts" />
    /// <reference lib="scripthost" />
    /// <reference lib="dom.iterable" />
    /// <reference lib="dom.asynciterable" />
    
  • In es2024.full.d.ts, we can see that (e.g.) the subcategory "DOM.Iterable" is not yet part of its category "DOM". Among other things, it enables iteration over NodeLists – e.g.:

    for (const x of document.querySelectorAll('div')) {}
    
  • If you need a type for a built-in feature then you can search the TypeScript repository to find out which value you have to add to lib in order to get it.

Types for the built-in Node.js APIs  

The types for the Node.js APIs must be installed via an npm package:

npm install @types/node

Module system  

module: How does TypeScript look for imported modules?  

These options affect how TypeScript looks for imported modules:

"compilerOptions": {
  "module": "NodeNext",
  "noUncheckedSideEffectImports": true,
}
  • module: With this option, we specify systems for handling modules. If we set it up correctly, we also take care of the related option moduleResolution, for which it provides good defaults. The TypeScript documentation recommends either of the following two values:

    • Node.js: "NodeNext" supports both CommonJS and the latest ESM features.
      • Implies "moduleResolution": "NodeNext"
    • Bundlers: "Preserve" supports both CommonJS and the latest ESM features. This behavior matches what most bundlers do.
      • Implies "moduleResolution": "bundler"

    Given that bundlers mostly mimic what Node.js does, I’m always using "NodeNext" and haven’t encountered any issues.

    Note that in both cases, TypeScript forces us to mention the complete names of local modules we import. We can’t omit filename extensions as was frequent practice when Node.js was only compiled to CommonJS. The new approach mirrors how pure-JavaScript ESM works.

  • noUncheckedSideEffectImports: By default, TypeScript does not complain if an empty import does not exist. The reason for this behavior is that this is a pattern supported by some bundlers to associate non-TypeScript artifacts with modules. And TypeScript only sees TypeScript files. This is what such an import looks like:

    import './component-styles.css';
    

    Interestingly, TypeScript normally is also OK with emptily imported TypeScript files that don’t exist. It only complains if we import something from a non-existent file.

    import './does-not-exist.js'; // no error!
    

    Setting noUncheckedSideEffectImports to true changes that. I’m explaining an alternative for importing non-TypeScript artifacts later.

Running TypeScript directly (without generating JS files)  

Most non-browser JavaScript platforms now can run TypeScript code directly, without transpiling it.

"compilerOptions": {
  "allowImportingTsExtensions": true,
  // Only needed if compiling to JavaScript:
  "rewriteRelativeImportExtensions": true,
}
  • allowImportingTsExtensions: This option lets us refer to the TypeScript version of a module when importing, not its transpiled version.

  • rewriteRelativeImportExtensions: With this option, we can also transpile TypeScript code that is meant to be run directly. By default, TypeScript does not change the module specifiers of imports. This option comes with a few caveats:

    • Only relative paths are rewritten.
    • They are rewritten “naively” – without taking the options baseUrl and paths into consideration (which are beyond the scope of this blog post).
    • Paths that are routed via the "exports" and "imports" properties don’t look like relative paths and are therefore not rewritten either.

If you want to use tsc for type checking (only), then take a look at the section on the noEmit option.

For more information on Node’s built-in support for TypeScript, you can read my blog post.

Importing JSON  

"compilerOptions": {
  "resolveJsonModule": true,
}

The option resolveJsonModule enables us to import JSON files:

import config from './config.json' with {type: 'json'};
console.log(config.hello);

Importing other non-TypeScript artifacts  

Whenever we import a file basename.ext whose extension ext TypeScript doesn’t know, it looks for a file basename.d.ext.ts. If it can’t find it, it raises an error. The TypeScript documentation has a good example of what such a file can look like.

There are two ways in which we can prevent TypeScript from raising errors for unknown imports.

First, we can use option allowArbitraryExtensions to prevent any kind of error reporting in this case.

Second, we can create an ambient module declaration with a wildcard specifier – a .d.ts file that has to be somewhere among the files that TypeScript is aware of. The following example suppresses errors for all imports with the filename extension .css:

// ./src/globals.d.ts
declare module "*.css" {}

Type checking  

"compilerOptions": {
  "strict": true,
  "exactOptionalPropertyTypes": true,
  "noFallthroughCasesInSwitch": true,
  "noImplicitOverride": true,
  "noImplicitReturns": true,
  "noPropertyAccessFromIndexSignature": true,
  "noUncheckedIndexedAccess": true,
}

strict is a must, in my opinion. With the remaining settings, you have to decide for yourself if you want the additional strictness for your code. You can start by adding all of them and see which ones cause too much trouble for your taste. In this section, we’ll ignore settings that are covered by strict (such as noImplicitAny).

  • noFallthroughCasesInSwitch: If true, non-empty switch cases must end with break, return or throw.

  • noImplicitOverride: If true then methods that override superclass methods must have the override modifier.

  • noImplicitReturns: If true then an “implicit return” (the function or method ending) is only allowed if the return type is void.

exactOptionalPropertyTypes  

If true then .colorTheme can only be omitted, not set to undefined in the following example:

interface Settings {
  // Absent property means “system”
  colorTheme?: 'dark' | 'light';
}
const obj1: Settings = {}; // allowed
const obj2: Settings = {colorTheme: undefined}; // not allowed

noPropertyAccessFromIndexSignature  

If true then for types such as the following one, we cannot use the dot notation for unknown properties, only for known ones:

interface ObjectWithId {
  id: string,
  [key: string]: string;
}
declare const obj: ObjectWithId;

const value1 = obj.id; // allowed
const value2 = obj['unknownProp']; // allowed
const value3 = obj.unknownProp; // not allowed

noUncheckedIndexedAccess  

If true then the type of an unknown property is the union of undefined and the type of the index signature:

interface ObjectWithId {
  id: string,
  [key: string]: string;
}
declare const obj: ObjectWithId;
expectType<string>(obj.id);
expectType<undefined | string>(obj.unknownProp);

noUncheckedIndexedAccess and Arrays  

The option noUncheckedIndexedAccess also affects how Arrays are handled:

const arr = ['a', 'b'];
const elem = arr[0];
expectType<undefined | string>(elem);

If this setting is false then elem has the type string.

One common pattern for Arrays is to check the length before accessing an element. However, that pattern becomes inconvenient with noUncheckedIndexedAccess:

function logElemAt0(arr: Array<string>) {
  if (0 < arr.length) {
    const elem = arr[0];
    expectType<undefined | string>(elem);
    console.log(elem);
  }
}

Therefore, it makes more sense to use a different pattern:

function logElemAt0(arr: Array<string>) {
  if (0 in arr) {
    const elem = arr[0];
    expectType<string>(elem);
    console.log(elem);
  }
}

I’m torn about this option: On one hand, the new pattern reflects that Arrays can contain holes. On the other hand, holes are rare and, since ES6, JavaScript pretends that they are elements that have the value undefined:

> Array.from([,,,])
[ undefined, undefined, undefined ]

Type checking options that have good defaults  

By default, the following options produce warnings in editors, but we can also choose to produce compiler errors or ignore problems:

  • allowUnreachableCode
  • allowUnusedLabels
  • noUnusedLocals
  • noUnusedParameters

Interoperability: helping external tools compile TypeScript to JavaScript and declarations  

"compilerOptions": {
  "verbatimModuleSyntax": true,
  "isolatedDeclarations": true,
}

The TypeScript compiler performs three tasks:

  1. Type checking
  2. Emitting JavaScript files
  3. Emitting declaration files

Nowadays, external tools have become popular that do #2 and #3 much faster. These have two needs:

  • Emitting the output file should not require looking up information in files imported by the input file.
  • It also should not require semantic analysis; only syntactic analysis.

There are two settings that enforce these constraints statically – they cause compiler errors but do not change how JavaScript and declarations are emitted:

  • verbatimModuleSyntax helps with compiling TypeScript to JavaScript.
  • isolatedDeclarations helps with compiling TypeScript to declarations.

verbatimModuleSyntax: compiling TypeScript to JavaScript  

Most non-JavaScript parts of a TypeScript file are easy to detect. The exception are imports: Without a (relatively simple) semantic analysis, we don’t know if an import is a (TypeScript) type or a (JavaScript) value.

If verbatimModuleSyntax is active, we are forced to add they keyword type to type-only imports – e.g.:

// Input
import {type SomeInterface, SomeClass} from './my-module.js';

// Output
import {SomeClass} from './my-module.js';

Note that a class is both a value and a type. In that case, no type keyword is needed because that part of the syntax can stay in plain JavaScript.

We also need to add type if we mention a type in an export clause:

interface MyInterface {}
export {type MyInterface};

// Alternative:
export interface MyInterface {}

isolatedModules  

Activating verbatimModuleSyntax also activates isolatedModules, which is why we only need the former setting. The latter prevents us from using some more obscure features that are also problematic.

As an aside, this option enables esbuild to compile TypeScript to JavaScript (source).

isolatedDeclarations: compiling TypeScript to declarations  

isolatedDeclarations mainly forces us to add return type annotations to exported functions and methods. That means that external tools won’t have to infer return types.

Further reading: The TypeScript 5.5 release notes have a comprehensive section on isolated declarations.

noEmit: only using tsc for type checking  

Sometimes, we only want to use tsc for type checking – e.g., if we run TypeScript directly or use external tools for compiling TypeScript files (to JavaScript files, declaration files, etc.):

"compilerOptions": {
  "noEmit": true,
}
  • noEmit: If true, we can run tsc and it will only type-check the TypeScript code, it won’t emit any files.

Whether or not you additionally want to remove output-related options depends on which ones of them are used by your external tools.

Importing CommonJS from ESM  

One key issue affects importing a CommonJS module from an ESM module:

  • In ESM, the default export is the property .default of the module namespace object.
  • In CommonJS, the module object is the default export – e.g., there are many CommonJS modules that set module.exports to a function.

Let’s look at two options that help.

allowSyntheticDefaultImports: type-checking default imports of CommonJS modules  

This option only affects type-checking not the JavaScript code emitted by TypeScript: If active, a default import of a CommonJS module refers to module.exports (not module.exports.default) – but only if there is no module.exports.default.

This reflects how Node.js handles default imports of CommonJS modules (source): “When importing CommonJS modules, the module.exports object is provided as the default export. Named exports may be available, provided by static analysis as a convenience for better ecosystem compatibility.”

Do we need this option? Yes, but it’s automatically activated if moduleResolution is "bundler" or if module is "NodeNext" (which activates esModuleInterop which activates allowSyntheticDefaultImports).

esModuleInterop: better compilation of TypeScript to CommonJS code  

This option affects emitted CommonJS code:

  • If false:
    • import * as m from 'm' is compiled to const m = require('m').
    • import m from 'm' is (roughly) compiled to const m = require('m') and every access of m is compiled to m.default.
  • If true:
    • import * as m from 'm' assigns a new object to m that has the same properties as module.exports plus a property .default that refers to module.exports.
    • import m from 'm' assigns a new object to m that has a single property .default that refers to module.exports. Every access of m is compiled to m.default.
  • If a CommonJS module has the marker property .__esModule then it is always imported as if esModuleInterop were switched off.

Do we need this option? No, since we only author ESM modules.

More options with good defaults  

We can usually ignore these options:

  • moduleDetection: This option configures how TypeScript determines whether a file is a script or a module. It can usually be omitted because its default "auto" works well in most cases. You only need to explicitly set it to "force" if your codebase has a module that has neither imports nor exports. If module is "NodeNext" and package.json has "type":"module" then even those files are interpreted as modules.

  • skipLibCheck: Unless you are doing something fancy with declaration files, you can probably ignore this option and simply go with the default setting. According to a discussion on GitHub, there are mostly downsides to setting it to true (the default is false).

package.json settings considered by TypeScript  

TypeScript takes several package.json properties into consideration:

  • type: This is an important setting. If you compile to ESM modules, your package.json should always contain:

    "type": "module"
    
  • exports specifies which files of a package are publicly visible and remap paths (so that what importers see is different from the actual internal paths). All of these settings can be applied conditionally – depending on the importing environment (browsers, Node.js, etc.). For more information, see my blog post “TypeScript and native ESM on Node.js”. One neat feature of package exports is that we can refer to our own package via a bare import and the package exports rules will be applied. That is useful for unit tests.

  • imports lets us define abbreviations such as #util for internal modules and external packages. For more information, see chapter “Packages: JavaScript’s units for software distribution” of my book “Shell scripting with Node.js”.

Visual Studio Code  

If you are unhappy with the module specifiers for local imports in automatically created imports then you can take a look at the following two settings:

javascript.preferences.importModuleSpecifierEnding
typescript.preferences.importModuleSpecifierEnding

By default, VSC should now be smart enough to add filename extensions where necessary.

Summary  

After having done the research for this blog post, this is the “base” I’m currently using. The following subsections explain how to adapt it to various use cases.

{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    // Specify explicitly (don’t derive from source file paths):
    "rootDir": ".",
    "outDir": "dist",

    //===== Output: JavaScript =====
    "target": "ES2024",
    "module": "NodeNext", // sets up "moduleResolution"
    // Emptily imported modules must exist
    "noUncheckedSideEffectImports": true,
    //
    "sourceMap": true, // .js.map files

    //===== Interoperability: help external tools =====
    // Helps tools that compile .ts to .js by enforcing
    // `type` modifiers for type-only imports etc.
    "verbatimModuleSyntax": true,

    //===== Type checking =====
    "strict": true, // activates several useful options
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUncheckedIndexedAccess": true,

    //===== Other options =====
    // Lets us import JSON files:
    "resolveJsonModule": true,
  }
}

Notes:

  • For more information on picking a good target, see the section on this topic earlier in this blog post.
  • verbatimModuleSyntax: I like the constraints it imposes on my code: tsc works without them, but they are needed for many tools that compile TypeScript to JavaScript.

npm package (libraries etc.)  

"compilerOptions": {
  // ···
  //===== Output: declarations =====
  "declaration": true, // .d.ts files
  // “Go to definition” jumps to TS source etc.:
  "declarationMap": true, // .d.ts.map files

  //===== Interoperability: help external tools =====
  // Helps tools that compile .ts to .d.ts by enforcing
  // return type annotations for exported functions, etc.
  "isolatedDeclarations": true,

  //===== Misc =====
  "lib": ["ES2024"], // don’t provide types for DOM
}

Notes:

  • isolatedDeclarations: I’d love to always use it, but TypeScript only allows it if option declaration or option composite are active.
  • If your library uses the DOM, you should remove "lib".

Node.js application  

"compilerOptions": {
  // ···
  //===== Misc =====
  "lib": ["ES2024"], // don’t provide types for DOM
}

Web app  

"module":"NodeNext" should work well for bundlers, too. But you can switch to the more bundler-specific "module":"preserve".

Running TypeScript directly (without generating JS files)  

"compilerOptions": {
  "allowImportingTsExtensions": true,
  // Only needed if compiling to JavaScript:
  "rewriteRelativeImportExtensions": true,
}

For more information, see section “Running TypeScript directly”.

Using tsc only for type checking  

"compilerOptions": {
  "noEmit": true,
}

For more information, see section “Only using tsc for type checking”.

tsconfig.json recommendations by other people  

Sources of this blog post  

Some of the sources were already mentioned earlier. These are additional sources I used: