tsconfig.json
Version: TypeScript 5.8
erasableSyntaxOnly
.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?
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 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.
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.
{
"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
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.
"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" ],
"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 () {}
.
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.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. 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).
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:
"ES20YY"
usually includes all of its subcategories."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
.
The types for the Node.js APIs must be installed via an npm package:
npm install @types/node
These options affect how TypeScript looks for imported modules:
"compilerOptions": {
"module": "Node16",
"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:
"Node16"
supports both CommonJS and the latest ESM features.
"moduleResolution": "Node16"
"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."Preserve"
supports both CommonJS and the latest ESM features. It matches what most bundlers do.
"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.
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.
"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:
baseUrl
and paths
into consideration (which are beyond the scope of this blog post)."exports"
and "imports"
properties in package.json
don’t look like relative paths and are therefore not rewritten either.Related option:
noEmit
option.Node.js now supports TypeScript via type stripping:
erasableSyntaxOnly
that helps with it"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, // 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 ]
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
The TypeScript compiler tsc performs three tasks:
Nowadays, external tools have become popular that do #2 and #3 much faster. These have two needs:
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.
"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.
.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).
.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:
The second point means that several TypeScript features are not supported – e.g.:
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).
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.
.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 whendeclaration
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.
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 "Node16"
(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 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.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.
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.
{
"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:
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.isolatedDeclarations
, but TypeScript only allows it if option declaration
or option composite
are active (more information).There is no need to change anything!
"module":"Node16"
should work well for bundlers, too. But you can switch to the more bundler-specific "module":"Preserve"
."lib"
if you want to use the DOM (more information)."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).
Should tsc only type-check and not emit any files?
noEmit
to true
(more information).Do external tools generate .js
files via transpilation?
verbatimModuleSyntax
to true
(more information).Do external tools generate .js
files via type stripping?
erasableSyntaxOnly
and verbatimModuleSyntax
to true
(more information)."compilerOptions": {
"allowImportingTsExtensions": true,
// Only needed if compiling to JavaScript:
"rewriteRelativeImportExtensions": true,
}
erasableSyntaxOnly
helps – which is already part of the base configuration.For more information on this topic, see section “Running TypeScript directly”.
tsconfig.json
recommendations by other people base.json
tsconfig.json
Some of the sources were already mentioned earlier. These are additional sources I used: