tsconfig.json
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:
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?
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:
allowJs
and checkJs
.composite
etc. For more information on this topic, see:
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.
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.
{
"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 filesinclude
: 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."compilerOptions": {
"rootDir": ".",
"outDir": "dist",
}
How TypeScript determines where to write an output file:
tsconfig.json
),rootDir
andoutDir
.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.
"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.
.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.
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
.
"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:
"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 () {}
.
tsconfig/bases
."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.
We have to pick an ECMAScript version that works for our target platforms. There are two tables that provide good overviews:
compat-table.github.io
node.green
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.
The types for the Node.js APIs must be installed via an npm package:
npm install @types/node
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:
"NodeNext"
supports both CommonJS and the latest ESM features.
"moduleResolution": "NodeNext"
"Preserve"
supports both CommonJS and the latest ESM features. This behavior matches what most bundlers do.
"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.
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:
baseUrl
and paths
into consideration (which are beyond the scope of this blog post)."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.
"compilerOptions": {
"resolveJsonModule": true,
}
The option resolveJsonModule
enables us to import JSON files:
import config from './config.json' with {type: 'json'};
console.log(config.hello);
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" {}
"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 ]
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
"compilerOptions": {
"verbatimModuleSyntax": true,
"isolatedDeclarations": true,
}
The TypeScript compiler performs three tasks:
Nowadays, external tools have become popular that do #2 and #3 much faster. These have two needs:
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.
One key issue affects importing a CommonJS module from an ESM module:
.default
of the module namespace object.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:
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
.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
..__esModule
then it is always imported as if esModuleInterop
were switched off.Do we need this option? No, since we only author ESM modules.
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”.
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.
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:
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."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."lib"
."compilerOptions": {
// ···
//===== Misc =====
"lib": ["ES2024"], // don’t provide types for DOM
}
"module":"NodeNext"
should work well for bundlers, too. But you can switch to the more bundler-specific "module":"preserve"
.
"compilerOptions": {
"allowImportingTsExtensions": true,
// Only needed if compiling to JavaScript:
"rewriteRelativeImportExtensions": true,
}
For more information, see section “Running TypeScript directly”.
"compilerOptions": {
"noEmit": true,
}
For more information, see section “Only using tsc for type checking”.
tsconfig.json
recommendations by other people base.json
tsconfig.json
Some of the sources were already mentioned earlier. These are additional sources I used: