A guide to tsconfig.json

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

Version: TypeScript 5.8


I never felt confident about my tsconfig.json. To change that, I went through the official documentation, collected all common options, and documented them in this blog post:

  • This knowledge will enable you to write a tsconfig.json that is cleaner and that you’ll fully understand.

  • If you don’t have the time to read the post, you can jump to the summary at the end where I show the tsconfig.json that I use now – along with recommendations for adapting it to different use cases (npm package, app, etc.).

  • I also link to the tsconfig.json recommendations by several well-known TypeScript programmers. (I went through them when I researched this post.)

I’m curious what your experiences with tsconfig.json are: Do you agree with my choices?

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 used in this blog post  

For showing inferred types in source code, I use the npm package 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 suit 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

What happens if we remove rootDir from tsconfig.json? Then TypeScript computes a default value: Because the paths matched by the include patterns have no common prefix, that default is ".". Therefore, the output remains the same.

If we additionally remove one of the two include patterns then the output changes:

{
  "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" ],
  "skipLibCheck": true,
}

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.

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. If you are looking for a type, then search those files!

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

  • The values are case-insensitive: Visual Studio Code’s autocompletion suggestions contain many capital letters; the filenames contain none. lib values can be written either way.

When does TypeScript support a given API? It must be “available un-prefixed/flagged in at least 2 browser engines (i.e. not just 2 chromium browsers)” (source).

Setting up lib via target  

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 this file, we can observe an interesting phenomenon:

  • Category "ES20YY" usually includes all of its subcategories.
  • Category "DOM" doesn’t – e.g., subcategory "DOM.Iterable" is not yet part of it.

Among other things, "DOM.Iterable" enables iteration over NodeLists – e.g.:

for (const x of document.querySelectorAll('div')) {}

skipLibCheck  

  • skipLibCheck:false – By default, TypeScript type-checks all .d.ts files. This is normally not necessary but helps when hand-writing .d.ts files (source).

  • skipLibCheck:true – If we switch it off, then TypeScript will only type-check library functionality we use in our code. That saves time – which is why I went with true.

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  

How does TypeScript look for imported modules?  

These options affect how TypeScript looks for imported modules:

"compilerOptions": {
  "module": "Node16",
  "noUncheckedSideEffectImports": true,
}

Option 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: "Node16" supports both CommonJS and the latest ESM features.
    • Implies "moduleResolution": "Node16"
    • There is also "NodeNext" but that is a moving target. It’s currently equivalent to "Node16" but that may change in the future – which might break existing code bases.
  • Bundlers: "Preserve" supports both CommonJS and the latest ESM features. It matches what most bundlers do.
    • Implies "moduleResolution": "bundler"

Given that bundlers mostly mimic what Node.js does, I’m always using "Node16" 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.

module:Node16 implies target:es2022 but in this case, I prefer to manually set up target because module and target are not as closely related as module and moduleResolution. Furthermore, module:Bundler does not imply anything.

Option 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)  

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

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

This mainly affects what filename extension we use when we import a local module. Traditionally, TypeScript does not change module specifiers and we have to use the filename extension .js in ESM modules (which is what works in the JavaScript that our TypeScript is compiled to):

import {someFunc} from './lib/utilities.js';

If we run TypeScript directly, that import statement looks like this:

import {someFunc} from './lib/utilities.ts';

This is enabled via the following settings:

  • allowImportingTsExtensions: If this option is active, TypeScript won’t complain if we use the filename extension .ts.

  • 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 in package.json don’t look like relative paths and are therefore not rewritten either.

Related option:

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

Node’s built-in support for TypeScript  

Node.js now supports TypeScript via type stripping:

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, // remove if not helpful
  "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, in the following example, .colorTheme can only be omitted, not set to undefined:

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

I’m torn about this option. In my projects, I’m initially setting it to true. If it leads to too many complaints then I remove it.

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

Compiling TypeScript with tools other than tsc  

The TypeScript compiler tsc 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 such as type inference; only syntactic analysis (simple parsing).

The following subsections describe configuration options that help with those needs. They only enforce constraints via compiler errors and do not change what is emitted.

Using tsc only for type checking  

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

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

  • 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.

Generating .js files via transpilation  

"compilerOptions": {
  "verbatimModuleSyntax": true, // implies "isolatedModules"
}

When compiling TypeScript to JavaScript, we need to remove the TypeScript parts. Most of those parts 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 the keyword type to type-only imports – e.g.:

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

// Output: JavaScript
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};

Alternatively, we can use an inline export:

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 relatively obscure features that are also problematic.

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

Generating .js files via type stripping  

"compilerOptions": {
  "erasableSyntaxOnly": true, // TS 5.8+: type stripping
  "verbatimModuleSyntax": true, // implies "isolatedModules"
}

Type stripping is the simplest way of compiling TypeScript to JavaScript:

  • Compilation consists only of removing type syntax.
  • No language features are transpiled.

The second point means that several TypeScript features are not supported – e.g.:

  • JSX
  • Enums
  • Parameter properties in class constructors.
  • Namespaces
  • Future JavaScript that is compiled to current JavaScript

If we set erasableSyntaxOnly to true then TypeScript will complain during editing when we use those features.

Additionally, we should set "verbatimModuleSyntax" to true which performs other checks that are important for type stripping (more information).

Type stripping technique: replacing types with spaces  

One clever technique for type stripping was pioneered by the ts-blank-space tool (by Ashley Claymore for Bloomberg): Instead of simply removing the type syntax, it replaces it with spaces. That means that source code positions in the output don’t change. Therefore, any positions that show up (e.g.) in stack traces still work for the input and there is no need for source maps.

For example - input (TypeScript):

function describeColor(color: Color): string {
  return `Color named “${color.colorName}”`;
}
type Color = { colorName: string };
describeColor({ colorName: 'green' });

Output (JavaScript):

function describeColor(color       )         {
  return `Color named “${color.colorName}”`;
}

describeColor({ colorName: 'green' });

Note the empty line between the declaration of describeColor() and its invocation.

If you want to explore further, you can check out the ts-blank-space playground.

Generating .d.ts files  

"compilerOptions": {
  // Only allowed if `declaration` or `composite` are true
  "isolatedDeclarations": true,
}

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.

I’d love to always use isolatedDeclarations, but TypeScript only allows it if option declaration or option composite are active. Jake Bailey explains why that is:

At the implementation level, isolatedDeclarations diagnostics are extra declaration diagnostics produced by the declaration transformer, which we only run when declaration is enabled.

Theoretically it could be implemented such that isolatedDeclarations enables those checks (the diagnostics actually come from us running the transformer and then throwing away the resulting AST), but it is a change from the original design.

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

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 "Node16" (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.

One more option with a good default  

We can usually ignore this option:

  • 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 "Node16" and package.json has "type":"module" then even those files are interpreted as modules.

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  

The next subsection contains the base tsconfig.json I’m using after having done the research for this blog post. Subsequent subsections explain how to adapt it to various use cases.

Base configuration  

{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    // Specified explicitly (not derived from source file paths)
    "rootDir": ".",
    "outDir": "dist",

    //========== Output: JavaScript ==========
    "target": "ES2024",
    "lib": [ "ES2024" ], // remove if you want to use the DOM
    "skipLibCheck": true,
    "module": "Node16", // sets up "moduleResolution"
    // Emptily imported modules must exist
    "noUncheckedSideEffectImports": true,
    "sourceMap": true, // .js.map files

    //========== Compiling TS with tools other than tsc ==========
    //----- Emitting .js -----
    // Enforces keyword `type` for type imports etc.
    "verbatimModuleSyntax": true, // implies "isolatedModules"
    // - Forbids non-JavaScript language constructs such as JSX,
    //   enums, constructor parameter properties and namespaces
    // - Important for type stripping
    "erasableSyntaxOnly": true, // TS 5.8+

    //----- Emitting .d.ts -----
    // - Forbids inferred return types of exported functions etc.
    // - Only allowed if `declaration` or `composite` are true
    "isolatedDeclarations": true,

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

    //========== Non-code artifacts ==========
    // 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.
  • I’d love to always use isolatedDeclarations, but TypeScript only allows it if option declaration or option composite are active (more information).

Node.js application  

There is no need to change anything!

Web app  

  • "module":"Node16" should work well for bundlers, too. But you can switch to the more bundler-specific "module":"Preserve".
  • Remove "lib" if you want to use the DOM (more information).

npm package (library etc.)  

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

Note: If your library uses the DOM, you should remove "lib" (more information).

Compiling TypeScript with tools other than tsc  

  • Should tsc only type-check and not emit any files?

  • Do external tools generate .js files via transpilation?

    • Already part of the base configuration: Set verbatimModuleSyntax to true (more information).
  • Do external tools generate .js files via type stripping?

    • Already part of the base configuration: Set erasableSyntaxOnly and verbatimModuleSyntax to true (more information).

Running TypeScript directly (without generating JS files)  

"compilerOptions": {
  "allowImportingTsExtensions": true,
  // Only needed if compiling to JavaScript:
  "rewriteRelativeImportExtensions": true,
}
  • Some tools – such as Node.js – use type stripping to run TypeScript. Then erasableSyntaxOnly helps – which is already part of the base configuration.

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

Further reading  

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: